From bec44d6f508ae4982b0547242e69e8c2f18c9dac Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 04:23:35 -0400 Subject: [PATCH 1/7] Move the star field's generation into alrender LLVOWLSky::initStars laid the field out inline, from the fixed seed Phase 0 gave it. The star rework that follows bins, sorts and culls that field, and its tests need to build the same field the viewer draws without a viewer, so the generation moves to alrender as ALStarField: generate(count, dome radius) returns each star's centre and its colour already packed as the vertex stage takes it (the black-body sRGB in rgb, the intensity in alpha, rounded as LLColor4U rounds an LLColor4). The draws are made in the same order from the same generators, the fixed-seed std::mt19937_64 drawing once a star the seed of the star's own SplitMix64, so the field is the one Phase 0 laid out. Phase 0's code and ALStarField's are compiled apart, each inlining the band's density its own way, so where they round one of a star's band comparisons the other way, as expf may, that star differs, and no other. The black-body colour and the hemisphere sampling move with it; the meteors still use both, drawing from the global generator as before. How the field is drawn is a template over the Milky Way band's density, ALStarField::detail::generate(count, radius, density) in alstarfieldgenerate.h: generate calls it with milkyWayDensity, the band's Gaussian about the galactic plane, and the tests with densities of their own. alstarfield_test pins five stars of the default field across all three tiers, so a change of seed, draw order or tier shows, and checks that the field does not move with the global generator or a count in between, that each tier scales with the count, and that every star sits on the upper hemisphere in its tier's distance and intensity range. Through detail::generate it forces one band comparison the other way in each tier and checks that the star alone moves; with one stream feeding every star, the same flips move 15,387, 13,712 and 3,976 of a tenth-size field's 15,500 stars. generate's own field, its density compiled apart from the test's, differs from the test's at no more than one star in a thousand. alstarfield_gl_test draws the stars two ways on the hidden window from that field: through the star shaders as they are now, copied into the test as the reference the rework must keep and built as the viewer builds its own, by createShader from a tree of the viewer's class1 shaders with them beside, the engine's matrix blocks spliced in where they say //[ENGINE_BLOCK Matrices]; and through the product's deferred/starsV.glsl and starsF.glsl as LLViewerShaderMgr builds them, into a G-buffer of each path (four attachments with the emissive buffer, three without). At this commit the two are the same shaders, and the pictures match exactly in all six scenes (night looking up, at the horizon, wide at the zenith, moonlit with the moon in view, dusk, dusk with the moon up) on both paths. The moonlit scenes check that the moon's mask hides stars at all. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/CMakeLists.txt | 18 +- indra/alrender/alstarfield.cpp | 109 +++ indra/alrender/alstarfield.h | 87 +++ indra/alrender/alstarfieldgenerate.h | 172 +++++ indra/alrender/tests/alstarfield_gl_test.cpp | 705 +++++++++++++++++++ indra/alrender/tests/alstarfield_test.cpp | 256 +++++++ indra/newview/llvowlsky.cpp | 262 +------ indra/newview/llvowlsky.h | 7 +- 8 files changed, 1360 insertions(+), 256 deletions(-) create mode 100644 indra/alrender/alstarfield.cpp create mode 100644 indra/alrender/alstarfield.h create mode 100644 indra/alrender/alstarfieldgenerate.h create mode 100644 indra/alrender/tests/alstarfield_gl_test.cpp create mode 100644 indra/alrender/tests/alstarfield_test.cpp diff --git a/indra/alrender/CMakeLists.txt b/indra/alrender/CMakeLists.txt index e81a650191..1e4d3b3304 100644 --- a/indra/alrender/CMakeLists.txt +++ b/indra/alrender/CMakeLists.txt @@ -23,6 +23,7 @@ set( alshaderinputs.cpp alskydome.cpp alspotshadowcache.cpp + alstarfield.cpp altransienttargetpool.cpp altransienttargets.cpp alwaterfield.cpp @@ -46,6 +47,8 @@ set( alshaderinputs.h alskydome.h alspotshadowcache.h + alstarfield.h + alstarfieldgenerate.h altransienttargetpool.h altransienttargets.h alwaterfield.h @@ -76,7 +79,7 @@ if(AL_BUILD_TESTS) # that ask for it again, the transient target pool's policy, what the programs are built from, the far plane # and the constants its shaders share, the water's field and the wave # heights a normal map's slopes integrate to, snapshot cutouts, the spot - # shadows' cache, and how a display pass ends its frame. + # shadows' cache, how a display pass ends its frame, and the star field. al_add_test_binary( alrender_tests PROJECT alrender @@ -89,6 +92,7 @@ if(AL_BUILD_TESTS) tests/alrenderresourceplan_test.cpp tests/alshaderinputs_test.cpp tests/alspotshadowcache_test.cpp + tests/alstarfield_test.cpp tests/altransienttargetpool_test.cpp tests/alwaterfield_test.cpp tests/alwaterheightbake_test.cpp @@ -102,11 +106,12 @@ if(AL_BUILD_TESTS) # and the waves displaced by heights the bake integrates from a normal map, # a spot shadow draw's geometry from a real vertex buffer, # the light map's blur by one dispatch against its two fragment passes, - # WindLight's dome along the view ray against the dome's mesh, - # FXAA on an RGB10_A2 source, the reflection probes' taps in one pass over - # them against their list, the transient target pool's GL objects, - # shared depth and poison, and the previews' and bakes' targets taken from - # it only when one draws, on llrender's shared hidden window. + # WindLight's dome along the view ray against the dome's mesh, the stars + # against the picture they left before their rework, FXAA on an RGB10_A2 + # source, the reflection probes' taps in one pass over them against their + # list, the transient target pool's GL objects, shared depth and poison, + # and the previews' and bakes' targets taken from it only when one draws, + # on llrender's shared hidden window. al_add_test_binary( alrender_gl_tests PROJECT alrender @@ -116,6 +121,7 @@ if(AL_BUILD_TESTS) tests/alondemandtarget_gl_test.cpp tests/alskydome_gl_test.cpp tests/alspotshadowcache_gl_test.cpp + tests/alstarfield_gl_test.cpp tests/altransienttargetpool_gl_test.cpp tests/alwatersurface_gl_test.cpp tests/fxaa_gl_test.cpp diff --git a/indra/alrender/alstarfield.cpp b/indra/alrender/alstarfield.cpp new file mode 100644 index 0000000000..5f1d8b2410 --- /dev/null +++ b/indra/alrender/alstarfield.cpp @@ -0,0 +1,109 @@ +/** + * @file alstarfield.cpp + * @brief The night sky's stars: where they lie and the colour each one is. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alstarfield.h" + +#include "alstarfieldgenerate.h" +#include "llmath.h" +#include "v3color.h" +#include "v4color.h" + +#include +#include + +namespace +{ + // An arbitrary but fixed galactic pole: turning it would turn the Milky Way's band, which it sets running across + // the sky at a slight incline. + LLVector3 galacticPole() + { + LLVector3 pole(0.30f, 0.72f, 0.62f); + pole.normVec(); + return pole; + } +} + +namespace ALStarField +{ + +LLColor3 blackBodyColor(F32 temperature_K) +{ + const F32 t = temperature_K * 0.01f; // in hundreds of kelvin + + F32 r, g, b; + + if (t <= 66.0f) + { + r = 1.0f; + } + else + { + const F32 x = t - 60.0f; + r = 329.698727446f * powf(x, -0.1332047592f) / 255.0f; + } + + if (t <= 66.0f) + { + g = (99.4708025861f * logf(llmax(1.0f, t)) - 161.1195681661f) / 255.0f; + } + else + { + const F32 x = t - 60.0f; + g = 288.1221695283f * powf(x, -0.0755148492f) / 255.0f; + } + + if (t >= 66.0f) + { + b = 1.0f; + } + else if (t <= 19.0f) + { + b = 0.0f; + } + else + { + b = (138.5177312231f * logf(t - 10.0f) - 305.0447927307f) / 255.0f; + } + + return LLColor3(llclamp(r, 0.0f, 1.0f), llclamp(g, 0.0f, 1.0f), llclamp(b, 0.0f, 1.0f)); +} + +F32 milkyWayDensity(const LLVector3& direction) +{ + static const LLVector3 galactic_pole = galacticPole(); + + // 0 at the plane, 1 at the pole. + const F32 cos_to_pole = fabsf(direction * galactic_pole); + // A Gaussian density peaking at the plane. + return expf(-cos_to_pole * cos_to_pole * 10.0f); +} + +std::vector generate(U32 count, F32 dome_radius) +{ + return detail::generate(count, dome_radius, [](const LLVector3& direction) { return milkyWayDensity(direction); }); +} + +} // namespace ALStarField diff --git a/indra/alrender/alstarfield.h b/indra/alrender/alstarfield.h new file mode 100644 index 0000000000..693906eca4 --- /dev/null +++ b/indra/alrender/alstarfield.h @@ -0,0 +1,87 @@ +/** + * @file alstarfield.h + * @brief The night sky's stars: where they lie and the colour each one is. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "llmath.h" +#include "stdtypes.h" +#include "v3color.h" +#include "v3math.h" +#include "v4coloru.h" + +#include +#include + +/// The star field, worked from a star count and the dome's radius alone: no GL and no settings, so LLVOWLSky's buffer +/// and the GL tests that draw it share one definition. +/// +/// The stars' frame is the agent's turned slowly about z (LLDrawPoolWLSky::renderStarsDeferred): x and y on the +/// horizon, z up. Every star lies on the upper hemisphere. +namespace ALStarField +{ + +/// A star: its centre on its tier's sphere about the camera, and its colour as the vertex stage takes it, the +/// black-body colour (sRGB) in rgb and the star's intrinsic intensity in alpha. +struct Star +{ + LLVector3 mPosition; + LLColor4U mColour; +}; + +/// The stars a count of DEFAULT_COUNT lays out in each of the three tiers: bright primaries, mid stars and the faint +/// dust, at 1, 1.6 and 3 dome radii. Any other count scales every tier alike, so the mix stays the same. +constexpr U32 TIER_COUNT[3] = { 5000, 30000, 120000 }; +constexpr U32 DEFAULT_COUNT = TIER_COUNT[0] + TIER_COUNT[1] + TIER_COUNT[2]; + +/// The most stars a count may ask for. +constexpr U32 MAX_COUNT = 700000; + +/// Tanner Helland's black-body colour for a temperature in kelvin, sRGB 0..1: within a few percent over the visual +/// range 1000 K..40000 K, and the familiar red to white to blue of the stars. The meteors take theirs from it too. +LLColor3 blackBodyColor(F32 temperature_K); + +/// A direction uniform over the upper hemisphere (z >= 0), from two of @a draw's numbers in [0, 1): z, which is +/// uniform by area, then the azimuth. The meteors start from it too. +template LLVector3 uniformUpperHemisphere(Rand& draw) +{ + const F32 z = draw(); + const F32 phi = F_TWO_PI * draw(); + const F32 r = sqrtf(llmax(0.0f, 1.0f - z * z)); + return LLVector3(r * cosf(phi), r * sinf(phi), z); +} + +/// The Milky Way band's density at @a direction, a unit vector of the stars' frame: 1 on the galactic plane, falling as +/// a Gaussian to under 0.0001 at its pole. A star of the band keeps a direction drawn uniformly over the hemisphere +/// where a draw in [0, 1) falls under the density there. +F32 milkyWayDensity(const LLVector3& direction); + +/// The field of @a count stars, held to MAX_COUNT, about a dome of @a dome_radius, the band's stars drawn by +/// milkyWayDensity (detail::generate, alstarfieldgenerate.h). The stars draw from a generator of their own with a fixed +/// seed, so a count lays out the same field in every session and on every platform, and images of the sky compare. +/// Each star takes one draw of it, the seed of a generator of the star's own that every draw of the star comes from; so +/// where a platform's maths rounds one of a star's comparisons the other way, as expf may for the band's, that star may +/// differ, and no other does. +std::vector generate(U32 count, F32 dome_radius); + +} // namespace ALStarField diff --git a/indra/alrender/alstarfieldgenerate.h b/indra/alrender/alstarfieldgenerate.h new file mode 100644 index 0000000000..d7744b2cb8 --- /dev/null +++ b/indra/alrender/alstarfieldgenerate.h @@ -0,0 +1,172 @@ +/** + * @file alstarfieldgenerate.h + * @brief How the star field is drawn from its seed: ALStarField::generate's tiers and generators, over any density of + * the Milky Way's band. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "alrandmap.h" +#include "alstarfield.h" +#include "v4color.h" + +#include +#include +#include + +namespace ALStarField::detail +{ + +// The three tiers, layered at different simulated distances. The faint dust dominates the density, giving the eye +// a rich background without the per-star cost dominating memory. +struct StarTier +{ + F32 distance_factor; // multiplies the dome's radius + F32 intensity_floor; // the least intensity the tier's distribution maps to + F32 intensity_ceil; // the most + F32 milky_way_fraction; // 0..1, the fraction of the tier biased toward the galactic band +}; + +inline constexpr StarTier STAR_TIERS[] = { + // dist i_floor i_ceil mw_frac + { 1.00f, 0.55f, 1.00f, 0.25f }, // bright primary + { 1.60f, 0.18f, 0.60f, 0.35f }, // mid + { 3.00f, 0.05f, 0.22f, 0.55f }, // faint "star dust" +}; +static_assert(LL_ARRAY_SIZE(STAR_TIERS) == LL_ARRAY_SIZE(TIER_COUNT)); + +// The field's generator, with a fixed seed: it draws once a star, the seed of the star's own generator, so the +// draws a star takes, however many its direction's rejection needs, leave every later star's seed where it was. +class FieldRandom +{ +public: + U64 operator()() { return mGenerator(); } + +private: + static constexpr U64 SEED = 0x53746172466965ull; + std::mt19937_64 mGenerator{ SEED }; +}; + +// A star's own generator, SplitMix64 from its seed, its draws mapped to [0, 1) as ll_frand maps the global one's. +// Integer arithmetic alone, so a seed draws the same numbers on every platform. +class StarRandom +{ +public: + explicit StarRandom(U64 seed) : mState(seed) {} + + F32 operator()() { return ALRandMap::unitF32(next()); } + +private: + U64 next() + { + mState += 0x9E3779B97F4A7C15ull; + U64 z = mState; + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); + } + + U64 mState; +}; + +// A stellar temperature, roughly as the stars are counted: mostly cool M and K dwarfs, with a long tail toward the +// blue. +inline F32 sampleStellarTemperature(StarRandom& draw) +{ + const F32 u = draw(); + if (u < 0.0025f) return 18000.0f + draw() * 22000.0f; // O/B class (rare, very blue) + if (u < 0.02f) return 10000.0f + draw() * 8000.0f; // late-B / A class (blue-white) + if (u < 0.10f) return 7000.0f + draw() * 3000.0f; // F class (yellow-white) + if (u < 0.30f) return 5500.0f + draw() * 1500.0f; // G class (sun-like) + if (u < 0.60f) return 4000.0f + draw() * 1500.0f; // K class (orange) + return 2600.0f + draw() * 1400.0f; // M class (cool red, majority) +} + +// A power-law luminosity in [0, 1], heavily toward the faint end; the tier maps it to its range, so each tier's +// distribution reads as a magnitude scale. The exponent of 4 is a rough match to the Salpeter-like tail: most stars +// lie near the floor, a few reach the ceiling. +inline F32 samplePowerLawIntensity(StarRandom& draw) +{ + return powf(draw(), 4.0f); +} + +// A direction, rejection-sampled toward the galactic plane by the band's @a density where `milky_way_biased`: a +// Gaussian density about the plane yields a band a few tens of degrees wide, which reads as the Milky Way without +// a texture. +template +LLVector3 generateStarDirection(const Density& density, bool milky_way_biased, StarRandom& draw) +{ + constexpr U32 MAX_ATTEMPTS = 24; + + for (U32 attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) + { + LLVector3 dir = uniformUpperHemisphere(draw); + + if (!milky_way_biased) + return dir; + + if (draw() < density(dir)) + return dir; + } + + // All but never reached; it keeps the function total. + return uniformUpperHemisphere(draw); +} + +/// ALStarField::generate's field, its band's stars drawn by @a density: a callable from a unit direction of the stars' +/// frame to the band's density there, milkyWayDensity in the field the sky draws. +template +std::vector generate(U32 count, F32 dome_radius, const Density& density) +{ + const U32 target_total = llmin(count, MAX_COUNT); + const F32 scale = (F32)target_total / (F32)DEFAULT_COUNT; + + std::vector stars; + stars.reserve(target_total + LL_ARRAY_SIZE(STAR_TIERS)); + + FieldRandom field; + for (size_t t = 0; t < LL_ARRAY_SIZE(STAR_TIERS); ++t) + { + const StarTier& tier = STAR_TIERS[t]; + const F32 tier_radius = dome_radius * tier.distance_factor; + const U32 tier_count = (U32)((F32)TIER_COUNT[t] * scale + 0.5f); + + for (U32 i = 0; i < tier_count; ++i) + { + StarRandom draw(field()); + const bool in_milky_way = draw() < tier.milky_way_fraction; + const LLVector3 dir = generateStarDirection(density, in_milky_way, draw); + + // The tier's distance dims it through its range, which the power-law draw is mapped into. + const F32 raw = samplePowerLawIntensity(draw); + const F32 intensity = llclamp(tier.intensity_floor + raw * (tier.intensity_ceil - tier.intensity_floor), + 0.0f, 1.0f); + + const LLColor3 bb = blackBodyColor(sampleStellarTemperature(draw)); + + stars.push_back({ dir * tier_radius, LLColor4U(LLColor4(bb, intensity)) }); + } + } + return stars; +} + +} // namespace ALStarField::detail diff --git a/indra/alrender/tests/alstarfield_gl_test.cpp b/indra/alrender/tests/alstarfield_gl_test.cpp new file mode 100644 index 0000000000..ec9cedbf52 --- /dev/null +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -0,0 +1,705 @@ +/** + * @file alstarfield_gl_test.cpp + * @brief The stars two ways on the hidden window, from the same fixed-seed field: as they were drawn before their + * rework (a quad of six vertices a star, every term worked per fragment, into all four attachments), and as + * LLDrawPoolWLSky draws them now. The two must leave the same picture. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "alstarfield.h" + +#include "llgl.h" +#include "llglheaders.h" +#include "llglslshader.h" +#include "llglstates.h" +#include "llimage.h" +#include "llimagegl.h" +#include "llmatrix4a.h" +#include "llrender.h" +#include "llrendertarget.h" +#include "llshadermgr.h" +#include "llstrider.h" +#include "llvertexbuffer.h" + +#include "../../llrender/tests/llheadlessgl_fixture.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr S32 W = 480; + constexpr S32 H = 320; + constexpr F32 NEAR_CLIP = 0.5f; + constexpr F32 FAR_CLIP = 1024.f; + + // LLSettingsSky's DOME_RADIUS. + constexpr F32 DOME_RADIUS = 15000.f; + + // Where the camera is in the agent's frame. The stars are taken to it, so nothing should depend on it. + const LLVector3 CAMERA(118.f, -43.f, 27.f); + + // What the dome leaves in each attachment where the stars draw over it (deferred/skyF.glsl): its colour in the + // albedo, or in the emissive with the emissive buffer, and the sky's flags in the normal's alpha. + const F32 DOME[4] = { 0.021f, 0.034f, 0.071f, 1.f }; + const F32 NO_COLOUR[4] = { 0.f, 0.f, 0.f, 1.f }; + const F32 SKY_FLAGS[4] = { 0.f, 0.f, 0.f, 0.f }; + + // Half a step of 8 bits: what LLDrawPoolWLSky's brightness cull lets a left-out star add, at most. + constexpr F32 HALF_QUANTUM = 0.5f / 255.f; + + // A frame of the night sky: the camera's bearing clockwise from north, its pitch above the horizon and its + // vertical field of view, in degrees; how far the stars have turned about z, in degrees; the scintillation's + // time; the star brightness (custom_alpha); and the moon, its brightness 0 for none, its elevation and bearing in + // the stars' frame, and the half-size of its quad. + struct Scene + { + const char* mName; + F32 mBearing; + F32 mPitch; + F32 mFov; + F32 mRotation; + F32 mTime; + F32 mStarAlpha; + F32 mMoonBrightness; + F32 mMoonElevation; + F32 mMoonBearing; + F32 mMoonSize; + }; + + const Scene SCENES[] = { + { "night, looking up", 30.f, 35.f, 60.f, 17.f, 1234.5f, 0.5f, 0.f, 0.f, 0.f, 0.f }, + { "night, at the horizon", 200.f, -3.f, 60.f, 245.f, 40000.25f, 0.5f, 0.f, 0.f, 0.f, 0.f }, + { "night, zenith, wide", 75.f, 80.f, 100.f, 3.f, 77.f, 0.8f, 0.f, 0.f, 0.f, 0.f }, + { "moonlit, the moon in view", 120.f, 25.f, 50.f, 90.f, 5000.f, 0.5f, 0.9f, 30.f, 214.f, 0.08f }, + { "dusk", 300.f, 20.f, 60.f, 140.f, 321.f, 0.03f, 0.f, 0.f, 0.f, 0.f }, + { "dusk, moon up", 10.f, 45.f, 70.f, 300.f, 9.75f, 0.06f, 0.6f, 55.f, 305.f, 0.05f }, + }; + + // A direction in the stars' frame from an elevation and a bearing clockwise from north (+y), x east. + LLVector3 direction(F32 elevation, F32 bearing) + { + const F32 el = elevation * DEG_TO_RAD; + const F32 az = bearing * DEG_TO_RAD; + return LLVector3(sinf(az) * cosf(el), cosf(az) * cosf(el), sinf(el)); + } + + // The stars' shaders as they were before their rework (deferred/starsV.glsl and starsF.glsl at the fixed seed's + // commit), the engine's matrix blocks spliced in where they say so, as the viewer's own shaders take them: the + // picture the rework has to keep. + const char* kOldVertex = R"(//[ENGINE_BLOCK Matrices] +uniform vec2 screen_res; + +in vec3 position; +in vec4 diffuse_color; +in vec2 texcoord0; + +out vec3 vary_world_dir; +out vec2 vary_corner; +out vec4 vary_color; +out float vary_intensity; +out float vary_subpixel_fade; + +vec3 srgb_to_linear(vec3 c) +{ + return pow(max(c, vec3(0.0)), vec3(2.2)); +} + +void main() +{ + vec4 clip_center = modelview_projection_matrix * vec4(position, 1.0); + + float intensity = diffuse_color.a; + + float base_px = mix(1.8, 7.0, intensity); + float spike_px = smoothstep(0.90, 1.0, intensity) * 12.0; + float ideal_px = base_px + spike_px; + + const float min_px = 2.6; + float quad_pixels = max(ideal_px, min_px); + vary_subpixel_fade = (ideal_px * ideal_px) / (quad_pixels * quad_pixels); + + vec2 pixel_offset = texcoord0 * (quad_pixels * 0.5); + + vec2 ndc_offset = pixel_offset * (2.0 / max(screen_res, vec2(1.0))); + clip_center.xy += ndc_offset * clip_center.w; + +#ifdef REVERSE_Z + clip_center.z = 0.0; +#else + clip_center.z = clip_center.w; +#endif + + gl_Position = clip_center; + + vary_corner = texcoord0; + vary_world_dir = normalize(position); + vary_color = vec4(srgb_to_linear(diffuse_color.rgb), intensity); + vary_intensity = intensity; +} +)"; + + const char* kOldFragment = R"(/*[EXTRA_CODE_HERE]*/ +out vec4 frag_data[4]; + +in vec3 vary_world_dir; +in vec2 vary_corner; +in vec4 vary_color; +in float vary_intensity; +in float vary_subpixel_fade; + +uniform float custom_alpha; +uniform float time; +uniform vec3 moon_dir; +uniform float moon_brightness; + +uniform vec3 moonQuadCenter; +uniform vec3 moonQuadU; +uniform vec3 moonQuadV; +uniform sampler2D diffuseMap; + +float hash31(vec3 p) +{ + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yzx + 33.33); + return fract((p.x + p.y) * p.z); +} + +float valueNoise3D(vec3 p) +{ + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * (3.0 - 2.0 * f); + + float n000 = hash31(i + vec3(0.0, 0.0, 0.0)); + float n100 = hash31(i + vec3(1.0, 0.0, 0.0)); + float n010 = hash31(i + vec3(0.0, 1.0, 0.0)); + float n110 = hash31(i + vec3(1.0, 1.0, 0.0)); + float n001 = hash31(i + vec3(0.0, 0.0, 1.0)); + float n101 = hash31(i + vec3(1.0, 0.0, 1.0)); + float n011 = hash31(i + vec3(0.0, 1.0, 1.0)); + float n111 = hash31(i + vec3(1.0, 1.0, 1.0)); + + return mix(mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y), + mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y), + u.z); +} + +float airmassKastenYoung(float cos_zenith) +{ + float cz = clamp(cos_zenith, 0.02, 1.0); + float elev_deg = degrees(asin(cz)); + return 1.0 / (cz + 0.50572 * pow(max(6.07995 + elev_deg, 1.0), -1.6364)); +} + +void main() +{ + float r2 = dot(vary_corner, vary_corner); + float core = exp(-r2 * 9.0); + float halo = exp(-r2 * 1.8) * 0.06; + + float ax = 1.0 - abs(vary_corner.x); + float ay = 1.0 - abs(vary_corner.y); + float spike_h = exp(-abs(vary_corner.y) * 32.0) * ax; + float spike_v = exp(-abs(vary_corner.x) * 32.0) * ay; + float spike_strength = smoothstep(0.90, 1.0, vary_intensity); + float spike = (spike_h + spike_v) * spike_strength * 0.30; + + float shape = core + halo + spike; + + if (shape < 0.002) discard; + + float moon_facing = dot(vary_world_dir, moonQuadCenter); + if (moon_facing > 0.0) + { + vec3 on_quad = vary_world_dir / moon_facing - moonQuadCenter; + vec2 quad = vec2(dot(on_quad, moonQuadU), dot(on_quad, moonQuadV)); + if (all(lessThan(abs(quad), vec2(1.0))) && textureLod(diffuseMap, quad * 0.5 + 0.5, 0.0).a > 2./255.) + { + discard; + } + } + + float cos_zenith = vary_world_dir.z; + float airmass = airmassKastenYoung(cos_zenith); + + const vec3 k_ext = vec3(0.05, 0.13, 0.30); + vec3 extinction = exp(-airmass * k_ext); + + vec3 seed = vary_world_dir * 120.0; + + float n_fast = valueNoise3D(seed + vec3(0.0, 0.0, time * 2.3)); + float n_slow = valueNoise3D(seed * 0.37 + vec3(0.0, 0.0, time * 0.7)); + float twinkle_base = mix(n_fast, n_slow, 0.35); + + float twinkle_amount = clamp(0.18 + 0.14 * (airmass - 1.0), 0.0, 0.65) * vary_intensity; + float twinkle = 1.0 + (twinkle_base - 0.5) * 2.0 * twinkle_amount; + + vec3 chroma_noise = vec3( + valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.7 + 0.0)), + valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.3 + 5.7)), + valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 2.1 + 13.1))); + float chroma_amt = 0.18 * twinkle_amount; + vec3 color_jitter = 1.0 + (chroma_noise - 0.5) * 2.0 * chroma_amt; + + float moon_up = max(0.0, moon_dir.z); + float moon_power = moon_brightness * moon_up; + float sky_wash = moon_power * (1.0 - vary_intensity) * 0.55; + float moon_cos = max(0.0, dot(vary_world_dir, moon_dir)); + float direct_glare = pow(moon_cos, 10.0) * moon_power * 0.85; + float moon_fade = clamp(sky_wash + direct_glare, 0.0, 0.95); + + vec3 star_color = vary_color.rgb * color_jitter * extinction * twinkle; + star_color *= vary_intensity; + star_color *= custom_alpha; + star_color *= (1.0 - moon_fade); + star_color *= vary_subpixel_fade; + + vec3 premult = star_color * shape; + + float bloom_boost = 1.0 + vary_intensity * vary_intensity * 1.2; + + frag_data[1] = vec4(0.0); + frag_data[2] = vec4(0.0, 1.0, 0.0, GBUFFER_FLAG_SKIP_ATMOS); + +#if defined(HAS_EMISSIVE) + frag_data[0] = vec4(0.0); + frag_data[3] = vec4(premult * bloom_boost, shape); +#else + frag_data[0] = vec4(premult * (1.0 + vary_intensity), shape); +#endif +} +)"; + + // The attachments of one draw, read back: every channel of every pixel, as floats. + struct Picture + { + std::vector> mAttachments; + + const F32* pixel(S32 attachment, S32 x, S32 y) const { return &mAttachments[attachment][(size_t(y) * W + x) * 4]; } + }; + + // How two pictures' star attachment differ, over the colour channels: the most, the pixels where it differs at + // all and where by half a step of 8 bits or more, and the pixels a star lit in either. + struct Difference + { + F32 mMost = 0.f; + S32 mDiffer = 0; + S32 mPastHalfQuantum = 0; + S32 mLit = 0; + }; + + struct alstarfield_gl_data + { + // The programs build from a tree holding the viewer's class1 shaders and the old ones beside them (setUp). + alstarfield_gl_data() + : mShaders(mTree.prefix()) + { + } + + // Every file of the viewer's class1 tree into the tree the programs build from, and the old stars' beside them. + void populateTree() + { + const std::filesystem::path root = fsyspath(std::string(AL_TEST_SHADER_DIR) + "/class1"); + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it(root, ec), end; !ec && it != end; it.increment(ec)) + { + if (!it->is_regular_file()) + { + continue; + } + std::string relative = fsyspath(std::filesystem::relative(it->path(), root)).string(); + std::replace(relative.begin(), relative.end(), '\\', '/'); + std::error_code read_ec; + const std::string source = LLFile::getContents(fsyspath(it->path()).string(), read_ec); + REQUIRE(!read_ec); + REQUIRE(mTree.write(relative, source)); + } + REQUIRE(!ec); + REQUIRE(mTree.write("startest/starsOldV.glsl", kOldVertex)); + REQUIRE(mTree.write("startest/starsOldF.glsl", kOldFragment)); + } + + ~alstarfield_gl_data() + { + LLGLSLShader::unbind(); + for (S32 path = 0; path < 2; ++path) + { + mOld[path].unload(); + mStars[path].unload(); + mTarget[path].release(); + } + mShaders.clearShaderObjects(); + mOldQuads = nullptr; + mMoonTexture = nullptr; + gGL.matrixMode(LLRender::MM_PROJECTION); + gGL.loadIdentity(); + gGL.matrixMode(LLRender::MM_MODELVIEW); + gGL.loadIdentity(); + } + + // The old program, from the text above, for the emissive path (1) or the albedo's (0): built as the viewer + // builds its own, its matrix blocks spliced from the engine's where it says so. + void buildOld(LLGLSLShader& program, bool emissive) + { + program.mName = "stars before their rework (test)"; + program.mShaderFiles.clear(); + program.mShaderFiles.push_back(std::make_pair("startest/starsOldV.glsl", GL_VERTEX_SHADER)); + program.mShaderFiles.push_back(std::make_pair("startest/starsOldF.glsl", GL_FRAGMENT_SHADER)); + program.mShaderLevel = 1; + if (emissive) + { + program.addPermutation("HAS_EMISSIVE", "1"); + } + UNSCOPED_INFO("the old star program builds"); + REQUIRE(program.createShader()); + } + + // The star program as LLViewerShaderMgr builds it. + void buildStars(LLGLSLShader& program, bool emissive) + { + program.mName = "Deferred Star Program"; + program.mShaderFiles.clear(); + program.mShaderFiles.push_back(std::make_pair("deferred/starsV.glsl", GL_VERTEX_SHADER)); + program.mShaderFiles.push_back(std::make_pair("deferred/starsF.glsl", GL_FRAGMENT_SHADER)); + program.mShaderLevel = 1; + program.mShaderGroup = LLGLSLShader::SG_SKY; + program.addConstant(LLGLSLShader::SHADER_CONST_STAR_DEPTH); + if (emissive) + { + program.addPermutation("HAS_EMISSIVE", "1"); + } + UNSCOPED_INFO("the star program builds"); + REQUIRE(program.createShader()); + } + + // A moon's texture: an opaque disc that fades out at its rim through every alpha the mask's 2 / 255 cut + // could fall between. + static LLPointer moonDisc() + { + constexpr S32 SIZE = 64; + LLPointer raw = new LLImageRaw(SIZE, SIZE, 4); + U8* data = raw->getData(); + for (S32 y = 0; y < SIZE; ++y) + { + for (S32 x = 0; x < SIZE; ++x) + { + const F32 u = ((F32)x + 0.5f) / SIZE * 2.f - 1.f; + const F32 v = ((F32)y + 0.5f) / SIZE * 2.f - 1.f; + const F32 r = sqrtf(u * u + v * v); + U8* px = data + (y * SIZE + x) * 4; + px[0] = px[1] = px[2] = 200; + px[3] = (U8)llclamp(std::lround(255.f * (0.9f - r) / 0.15f), 0L, 255L); + } + } + return new LLImageGL(raw, false); + } + + // The field at the default count; the old buffer, six vertices a star with their corner offsets, as + // LLVOWLSky::updateStarGeometry filled it; both paths' programs; and a G-buffer for each, the emissive one with + // four attachments and the albedo's with three. + void setUp() + { + populateTree(); + + // The objects every program gets. + std::map defines; + for (const auto& [object, stage] : { std::make_pair("deferred/textureUtilV.glsl", GL_VERTEX_SHADER), + std::make_pair("objects/nonindexedTextureV.glsl", GL_VERTEX_SHADER), + std::make_pair("deferred/globalF.glsl", GL_FRAGMENT_SHADER) }) + { + CAPTURE(object); + S32 level = 1; + REQUIRE(mShaders.loadShaderFile(object, level, stage, &defines) != 0); + } + + mField = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + REQUIRE(mField.size() == ALStarField::DEFAULT_COUNT); + + static const LLVector2 CORNERS[6] = { LLVector2(-1.f, -1.f), LLVector2(1.f, -1.f), LLVector2(1.f, 1.f), + LLVector2(-1.f, -1.f), LLVector2(1.f, 1.f), LLVector2(-1.f, 1.f) }; + mOldQuads = new LLVertexBuffer(LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_COLOR | LLVertexBuffer::MAP_TEXCOORD0); + REQUIRE(mOldQuads->allocateBuffer((U32)mField.size() * 6, 0)); + LLStrider vertices; + LLStrider colours; + LLStrider corners; + REQUIRE(mOldQuads->getVertexStrider(vertices)); + REQUIRE(mOldQuads->getColorStrider(colours)); + REQUIRE(mOldQuads->getTexCoord0Strider(corners)); + for (const ALStarField::Star& star : mField) + { + for (const LLVector2& corner : CORNERS) + { + *(vertices++) = star.mPosition; + *(colours++) = star.mColour; + *(corners++) = corner; + } + } + mOldQuads->unmapBuffer(); + + mMoonTexture = moonDisc(); + + for (S32 path = 0; path < 2; ++path) + { + buildOld(mOld[path], path == 1); + buildStars(mStars[path], path == 1); + REQUIRE(mTarget[path].allocate(W, H, GL_RGBA8)); + REQUIRE(mTarget[path].addColorAttachment(GL_RGBA8)); + REQUIRE(mTarget[path].addColorAttachment(GL_RGB10_A2)); + if (path == 1) + { + REQUIRE(mTarget[path].addColorAttachment(GL_R11F_G11F_B10F)); + } + } + } + + // The camera looking along @a scene from CAMERA: the agent's frame (east, north, up) turned to the eye's. + static void loadView(const Scene& scene) + { + gGL.matrixMode(LLRender::MM_PROJECTION); + const F32 tan_half = tanf(scene.mFov * 0.5f * DEG_TO_RAD); + LLMatrix4a proj; + proj.clear(); + F32* p = proj.getF32ptr(); + p[0] = 1.f / (tan_half * (F32)W / (F32)H); + p[5] = 1.f / tan_half; + p[10] = (FAR_CLIP + NEAR_CLIP) / (NEAR_CLIP - FAR_CLIP); + p[11] = -1.f; + p[14] = 2.f * FAR_CLIP * NEAR_CLIP / (NEAR_CLIP - FAR_CLIP); + gGL.loadMatrix(proj); + gGL.matrixMode(LLRender::MM_MODELVIEW); + gGL.loadIdentity(); + gGL.rotatef(-scene.mPitch, 1.f, 0.f, 0.f); + gGL.rotatef(-90.f, 1.f, 0.f, 0.f); + gGL.rotatef(scene.mBearing, 0.f, 0.f, 1.f); + gGL.translatef(-CAMERA.mV[VX], -CAMERA.mV[VY], -CAMERA.mV[VZ]); + } + + // The star uniforms on the bound @a program, as LLDrawPoolWLSky::renderStarsDeferred sets them, in the stars' + // frame; the moon's mask only where @a mask_moon. + void bindUniforms(LLGLSLShader& program, const Scene& scene, bool mask_moon = true) + { + program.uniform1f(LLShaderMgr::CUSTOM_ALPHA, scene.mStarAlpha); + program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (F32)W, (F32)H); + program.uniform1f(LLShaderMgr::WATER_TIME, scene.mTime); + + const LLVector3 moon = direction(scene.mMoonElevation, scene.mMoonBearing); + program.uniform3fv(LLShaderMgr::DEFERRED_MOON_DIR, 1, moon.mV); + program.uniform1f(LLShaderMgr::MOON_BRIGHTNESS, scene.mMoonBrightness); + if (mask_moon && scene.mMoonBrightness > 0.f) + { + // The quad on the plane a unit away along the moon, its axes scaled so the quad spans [-1, 1]. + LLVector3 u = moon % LLVector3(0.f, 0.f, 1.f); + u.normVec(); + LLVector3 v = u % moon; + v.normVec(); + u /= scene.mMoonSize; + v /= scene.mMoonSize; + program.uniform3fv(LLShaderMgr::MOON_QUAD_CENTER, 1, moon.mV); + program.uniform3fv(LLShaderMgr::MOON_QUAD_U, 1, u.mV); + program.uniform3fv(LLShaderMgr::MOON_QUAD_V, 1, v.mV); + const S32 channel = program.enableTexture(LLShaderMgr::DIFFUSE_MAP); + REQUIRE(channel > -1); + gGL.getTextureSlot(channel)->bindSampled(mMoonTexture, ALSamplers::AnisoClamp); + } + else + { + program.uniform3f(LLShaderMgr::MOON_QUAD_CENTER, 0.f, 0.f, 0.f); + } + } + + // The dome's attachments as the stars find them: the colour where the path keeps the sky's, and the flags. + void clearDome(bool emissive) + { + glClearBufferfv(GL_COLOR, 0, emissive ? NO_COLOUR : DOME); + glClearBufferfv(GL_COLOR, 1, NO_COLOUR); + glClearBufferfv(GL_COLOR, 2, SKY_FLAGS); + if (emissive) + { + glClearBufferfv(GL_COLOR, 3, DOME); + } + } + + // Read every attachment of the bound @a target back. + static Picture readBack(LLRenderTarget& target, S32 attachments) + { + Picture picture; + picture.mAttachments.resize(attachments); + for (S32 a = 0; a < attachments; ++a) + { + picture.mAttachments[a].resize(size_t(W) * H * 4); + glReadBuffer(GL_COLOR_ATTACHMENT0 + a); + glReadPixels(0, 0, W, H, GL_RGBA, GL_FLOAT, picture.mAttachments[a].data()); + } + glReadBuffer(GL_COLOR_ATTACHMENT0); + return picture; + } + + // The stars drawn the old way, every star a quad of six vertices, into the G-buffer of @a emissive's path; the + // moon's mask only where @a mask_moon. + Picture drawOld(const Scene& scene, bool emissive, bool mask_moon = true) + { + LLRenderTarget& target = mTarget[emissive]; + target.bindTarget(); + clearDome(emissive); + loadView(scene); + { + LLGLEnable blend(GL_BLEND); + LLGLDisable cull(GL_CULL_FACE); + LLGLDisable srgb(GL_FRAMEBUFFER_SRGB); + LLGLDepthTest no_depth(GL_FALSE, GL_FALSE); + gGL.setSceneBlendType(LLRender::BT_ADD); + gGL.pushMatrix(); + gGL.translatef(CAMERA.mV[VX], CAMERA.mV[VY], CAMERA.mV[VZ]); + gGL.rotatef(scene.mRotation, 0.f, 0.f, 1.f); + LLGLSLShader& program = mOld[emissive]; + program.bind(); + bindUniforms(program, scene, mask_moon); + mOldQuads->setBuffer(); + mOldQuads->drawArrays(LLRender::TRIANGLES, 0, mOldQuads->getNumVerts()); + program.unbind(); + gGL.popMatrix(); + gGL.setSceneBlendType(LLRender::BT_ALPHA); + } + Picture picture = readBack(target, emissive ? 4 : 3); + target.flush(); + return picture; + } + + // The stars drawn as LLDrawPoolWLSky::renderStarsDeferred draws them, into the G-buffer of @a emissive's path. + Picture drawStars(const Scene& scene, bool emissive) + { + LLRenderTarget& target = mTarget[emissive]; + target.bindTarget(); + clearDome(emissive); + loadView(scene); + { + LLGLEnable blend(GL_BLEND); + LLGLDisable cull(GL_CULL_FACE); + LLGLDisable srgb(GL_FRAMEBUFFER_SRGB); + LLGLDepthTest no_depth(GL_FALSE, GL_FALSE); + gGL.setSceneBlendType(LLRender::BT_ADD); + gGL.pushMatrix(); + gGL.translatef(CAMERA.mV[VX], CAMERA.mV[VY], CAMERA.mV[VZ]); + gGL.rotatef(scene.mRotation, 0.f, 0.f, 1.f); + LLGLSLShader& program = mStars[emissive]; + program.bind(); + bindUniforms(program, scene); + mOldQuads->setBuffer(); + mOldQuads->drawArrays(LLRender::TRIANGLES, 0, mOldQuads->getNumVerts()); + program.unbind(); + gGL.popMatrix(); + gGL.setSceneBlendType(LLRender::BT_ALPHA); + } + Picture picture = readBack(target, emissive ? 4 : 3); + target.flush(); + return picture; + } + + // The G-buffer of @a emissive's path as the dome leaves it, before any star. + Picture drawNothing(bool emissive) + { + LLRenderTarget& target = mTarget[emissive]; + target.bindTarget(); + clearDome(emissive); + Picture picture = readBack(target, emissive ? 4 : 3); + target.flush(); + return picture; + } + + // How the stars' attachment of two pictures differ, a star lighting a pixel where either differs from + // @a blank. + static Difference compare(const Picture& a, const Picture& b, const Picture& blank, S32 attachment) + { + Difference d; + for (S32 y = 0; y < H; ++y) + { + for (S32 x = 0; x < W; ++x) + { + const F32* pa = a.pixel(attachment, x, y); + const F32* pb = b.pixel(attachment, x, y); + const F32* pn = blank.pixel(attachment, x, y); + F32 most = 0.f; + bool lit = false; + for (S32 c = 0; c < 3; ++c) + { + most = llmax(most, fabsf(pa[c] - pb[c])); + lit = lit || pa[c] != pn[c] || pb[c] != pn[c]; + } + d.mMost = llmax(d.mMost, most); + d.mDiffer += most > 0.f; + d.mPastHalfQuantum += most >= HALF_QUANTUM; + d.mLit += lit; + } + } + return d; + } + + ll_test::SharedGLScope mGL; + ll_test::TestShaderDir mTree; + ll_test::TestShaderMgr mShaders; + std::vector mField; + LLPointer mOldQuads; + LLPointer mMoonTexture; + LLGLSLShader mOld[2]; + LLGLSLShader mStars[2]; + LLRenderTarget mTarget[2]; + }; +} + +TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the picture they left before their rework", + "[alstarfield_gl]") +{ + setUp(); + for (const Scene& scene : SCENES) + { + for (S32 path = 0; path < 2; ++path) + { + const bool emissive = path == 1; + const S32 attachment = emissive ? 3 : 0; + CAPTURE(scene.mName, emissive); + const Picture blank = drawNothing(emissive); + const Picture before = drawOld(scene, emissive); + const Picture after = drawStars(scene, emissive); + const Difference d = compare(before, after, blank, attachment); + CAPTURE(d.mMost, d.mDiffer, d.mPastHalfQuantum, d.mLit); + UNSCOPED_INFO("the scene shows stars"); + CHECK(d.mLit > 100); + CHECK(d.mMost < HALF_QUANTUM); + if (scene.mMoonBrightness > 0.f) + { + const Difference moon = compare(before, drawOld(scene, emissive, false), blank, attachment); + CAPTURE(moon.mDiffer); + UNSCOPED_INFO("the moon hides stars"); + CHECK(moon.mDiffer > 10); + } + } + } +} diff --git a/indra/alrender/tests/alstarfield_test.cpp b/indra/alrender/tests/alstarfield_test.cpp new file mode 100644 index 0000000000..cf127b1565 --- /dev/null +++ b/indra/alrender/tests/alstarfield_test.cpp @@ -0,0 +1,256 @@ +/** + * @file alstarfield_test.cpp + * @brief The star field: the same field from the same count in every session, laid out in its tiers. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include + +#include "altestmatchers.h" + +#include "../alstarfieldgenerate.h" +#include "../alstarfield.h" + +#include "llrand.h" + +#include +#include + +namespace +{ + // LLSettingsSky's DOME_RADIUS. + constexpr F32 DOME_RADIUS = 15000.f; + + // Each tier's distance and intensity range, in dome radii and intensity bytes. + struct Tier + { + F32 mDistance; + U8 mFloor; + U8 mCeil; + }; + constexpr Tier TIERS[] = { { 1.0f, 140, 255 }, { 1.6f, 46, 153 }, { 3.0f, 13, 56 } }; + + bool same(const ALStarField::Star& a, const ALStarField::Star& b) + { + return a.mPosition == b.mPosition && a.mColour == b.mColour; + } +} + +TEST_CASE("alstarfield: a count lays out the same field every time", "[alstarfield]") +{ + const std::vector first = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + + // The global generator moving on between the two, as the meteors and everything else move it, changes nothing. + for (S32 i = 0; i < 1000; ++i) + { + (void)ll_frand(); + } + const std::vector second = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + + REQUIRE(first.size() == second.size()); + size_t differ = 0; + for (size_t s = 0; s < first.size(); ++s) + { + differ += !same(first[s], second[s]); + } + CHECK(differ == 0); + + // Another count between, and back, also changes nothing. + (void)ALStarField::generate(ALStarField::DEFAULT_COUNT / 3, DOME_RADIUS); + const std::vector third = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + REQUIRE(third.size() == first.size()); + differ = 0; + for (size_t s = 0; s < first.size(); ++s) + { + differ += !same(first[s], third[s]); + } + CHECK(differ == 0); +} + +TEST_CASE("alstarfield: the default field is the one the fixed seed laid out before", "[alstarfield]") +{ + // The first stars of the default field, as LLVOWLSky::initStars laid them out from the fixed seed: a change of + // seed, draw order or tier would move every image of the night sky the sky's A/B captures compare. + struct Pinned + { + size_t mIndex; + LLVector3 mPosition; + LLColor4U mColour; + }; + const Pinned PINNED[] = { + { 0, LLVector3(11800.555f, -7005.780f, 6055.242f), LLColor4U(255, 235, 218, 140) }, + { 4999, LLVector3(-8317.633f, 7518.630f, 9964.296f), LLColor4U(255, 204, 163, 246) }, + { 5000, LLVector3(6414.705f, 9700.752f, 20993.975f), LLColor4U(255, 223, 196, 46) }, + { 35000, LLVector3(-9913.384f, 43411.914f, 6490.825f), LLColor4U(255, 210, 174, 13) }, + { 154999, LLVector3(21522.605f, -29961.686f, 25769.652f), LLColor4U(255, 213, 179, 14) }, + }; + + const std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + for (const Pinned& pinned : PINNED) + { + CAPTURE(pinned.mIndex); + REQUIRE(pinned.mIndex < stars.size()); + const ALStarField::Star& star = stars[pinned.mIndex]; + for (S32 c = 0; c < 3; ++c) + { + CAPTURE(c, star.mPosition.mV[c], pinned.mPosition.mV[c]); + CHECK(fabsf(star.mPosition.mV[c] - pinned.mPosition.mV[c]) < 0.05f); + } + for (S32 c = 0; c < 4; ++c) + { + CAPTURE(c, (S32)star.mColour.mV[c], (S32)pinned.mColour.mV[c]); + CHECK(abs((S32)star.mColour.mV[c] - (S32)pinned.mColour.mV[c]) <= 1); + } + } +} + +TEST_CASE("alstarfield: a band comparison that rounds the other way moves no other star", "[alstarfield]") +{ + // A platform whose expf rounds the band's density to the other side of a draw takes the other branch of that one + // comparison. Each run here forces one comparison's outcome, in each tier: the density at that candidate 0, which + // rejects it, or 1, which keeps it. One of the two takes the branch the field did not, and that star alone moves. + // Every run, the field's among them, goes through the one call below, so that each rounds every other comparison + // as the field does. Candidates of the bright tier, the mid tier and the dust: a tenth of the default count draws + // about 450, 3800 and 24000 candidates for the band. + constexpr U32 COUNT = ALStarField::DEFAULT_COUNT / 10; + constexpr U32 NONE = ~0u; + const U32 FLIPS[] = { 100u, 2000u, 20000u }; + struct Run + { + U32 mFlip; + F32 mForced; + }; + std::vector runs = { { NONE, 0.f } }; + for (const U32 flip : FLIPS) + { + runs.push_back({ flip, 0.f }); + runs.push_back({ flip, 1.f }); + } + std::vector> fields; + std::vector candidates; + for (const Run& run : runs) + { + U32 calls = 0; + fields.push_back(ALStarField::detail::generate( + COUNT, DOME_RADIUS, [&calls, run](const LLVector3& direction) + { return calls++ == run.mFlip ? run.mForced : ALStarField::milkyWayDensity(direction); })); + candidates.push_back(calls); + } + const std::vector& field = fields[0]; + + { + // The band's own density is the field's, but where the two, compiled apart, round one of its comparisons the + // other way, which moves that star alone. + const std::vector generated = ALStarField::generate(COUNT, DOME_RADIUS); + REQUIRE(generated.size() == field.size()); + size_t differ = 0; + for (size_t s = 0; s < field.size(); ++s) + { + differ += !same(field[s], generated[s]); + } + CAPTURE(differ); + CHECK(differ * 1000 <= field.size()); + } + + for (size_t f = 0; f < LL_ARRAY_SIZE(FLIPS); ++f) + { + size_t moved[2] = {}; + for (S32 forced = 0; forced < 2; ++forced) + { + const std::vector& other = fields[1 + 2 * f + forced]; + REQUIRE(other.size() == field.size()); + for (size_t s = 0; s < field.size(); ++s) + { + moved[forced] += !same(field[s], other[s]); + } + } + CAPTURE(FLIPS[f], candidates[1 + 2 * f], moved[0], moved[1]); + REQUIRE(candidates[1 + 2 * f] > FLIPS[f]); + CHECK(moved[0] + moved[1] == 1); + } +} + +TEST_CASE("alstarfield: every tier scales with the count and the count is held to the most", "[alstarfield]") +{ + CHECK(ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS).size() == ALStarField::DEFAULT_COUNT); + CHECK(ALStarField::generate(0, DOME_RADIUS).empty()); + // Each tier rounds its share, so the most may come to a star more or less than MAX_COUNT. + const size_t most = ALStarField::generate(ALStarField::MAX_COUNT, DOME_RADIUS).size(); + CHECK(most + 1 >= ALStarField::MAX_COUNT); + CHECK(most <= ALStarField::MAX_COUNT + 1); + CHECK(ALStarField::generate(ALStarField::MAX_COUNT * 4, DOME_RADIUS).size() == most); + + // A tenth of the default: a tenth of each tier, each on its own sphere. + const std::vector tenth = ALStarField::generate(ALStarField::DEFAULT_COUNT / 10, DOME_RADIUS); + std::vector per_tier(LL_ARRAY_SIZE(TIERS), 0); + for (const ALStarField::Star& star : tenth) + { + const F32 radii = star.mPosition.length() / DOME_RADIUS; + bool found = false; + for (size_t t = 0; t < LL_ARRAY_SIZE(TIERS); ++t) + { + if (fabsf(radii - TIERS[t].mDistance) < 1e-3f) + { + ++per_tier[t]; + found = true; + } + } + CAPTURE(radii); + REQUIRE(found); + } + for (size_t t = 0; t < LL_ARRAY_SIZE(TIERS); ++t) + { + CAPTURE(t); + CHECK(per_tier[t] == ALStarField::TIER_COUNT[t] / 10); + } +} + +TEST_CASE("alstarfield: every star lies on the upper hemisphere in its tier's intensity range", "[alstarfield]") +{ + const std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + size_t first = 0; + for (size_t t = 0; t < LL_ARRAY_SIZE(TIERS); ++t) + { + CAPTURE(t); + const size_t end = first + ALStarField::TIER_COUNT[t]; + REQUIRE(end <= stars.size()); + U8 least = 255; + U8 most = 0; + for (size_t s = first; s < end; ++s) + { + const ALStarField::Star& star = stars[s]; + CAPTURE(s); + REQUIRE(star.mPosition.mV[VZ] >= 0.f); + REQUIRE(fabsf(star.mPosition.length() / DOME_RADIUS - TIERS[t].mDistance) < 1e-3f); + least = llmin(least, star.mColour.mV[VALPHA]); + most = llmax(most, star.mColour.mV[VALPHA]); + } + CAPTURE((S32)least, (S32)most); + CHECK(least >= TIERS[t].mFloor); + CHECK(most <= TIERS[t].mCeil); + // The power law reaches near both ends of a tier's range. + CHECK(least <= TIERS[t].mFloor + 1); + CHECK(most >= TIERS[t].mCeil - 4); + first = end; + } +} diff --git a/indra/newview/llvowlsky.cpp b/indra/newview/llvowlsky.cpp index ee1a0a1bf0..f3825ae114 100644 --- a/indra/newview/llvowlsky.cpp +++ b/indra/newview/llvowlsky.cpp @@ -39,77 +39,11 @@ #include "llenvironment.h" #include "llsettingssky.h" -#include "alrandmap.h" #include "alskydome.h" +#include "alstarfield.h" -#include - -// Three tiers of stars, layered at different simulated distances. The counts -// stack to ~155k stars; the faint "dust" tier dominates density, giving the -// eye a rich background without per-star cost dominating memory. namespace { - struct StarTier - { - U32 count; // number of stars in this tier - F32 distance_factor; // multiplies dome radius - F32 intensity_floor; // minimum post-distribution intensity - F32 intensity_ceil; // maximum post-distribution intensity - F32 milky_way_fraction; // 0..1, fraction of this tier biased to galactic band - }; - - constexpr StarTier kStarTiers[] = { - // count dist i_floor i_ceil mw_frac - { 5000, 1.00f, 0.55f, 1.00f, 0.25f }, // bright primary - { 30000, 1.60f, 0.18f, 0.60f, 0.35f }, // mid - { 120000, 3.00f, 0.05f, 0.22f, 0.55f }, // faint "star dust" - }; - - // Default total used as the reference for the RenderStarCount setting: - // setting value N scales each tier by N / kDefaultStarTotal. - constexpr U32 kDefaultStarTotal = - kStarTiers[0].count + kStarTiers[1].count + kStarTiers[2].count; - - // Hard cap on configurable star count to bound peak VB allocation. - constexpr U32 kMaxStarTotal = 700000; - - // The stars draw from a generator of their own with a fixed seed, so a star count lays out the same field in - // every session and on every platform, and images of the sky compare. Each star takes one draw of it, the seed of - // a generator of the star's own that every draw of the star comes from; so where a platform's maths rounds one of - // a star's comparisons the other way, as expf may for the band's, that star may differ, and no other does. The - // meteors draw from the global one. - class FieldRandom - { - public: - U64 operator()() { return mGenerator(); } - - private: - static constexpr U64 SEED = 0x53746172466965ull; - std::mt19937_64 mGenerator{ SEED }; - }; - - // A star's own generator, SplitMix64 from its seed, its draws mapped to [0, 1) as ll_frand maps the global one's. - // Integer arithmetic alone, so a seed draws the same numbers on every platform. - class StarRandom - { - public: - explicit StarRandom(U64 seed) : mState(seed) {} - - F32 operator()() { return ALRandMap::unitF32(next()); } - - private: - U64 next() - { - mState += 0x9E3779B97F4A7C15ull; - U64 z = mState; - z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; - z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; - return z ^ (z >> 31); - } - - U64 mState; - }; - // The global generator, for the meteors. F32 globalRandom() { return ll_frand(); } @@ -119,96 +53,6 @@ namespace constexpr U32 kMeteorHardMax = 64; constexpr U32 kMeteorVertsPerQuad = 6; - // Arbitrary but fixed galactic pole - rotating this would rotate the Milky Way band. - // Chosen so the band runs across the sky at a slight incline. - inline LLVector3 getGalacticPole() - { - LLVector3 pole(0.30f, 0.72f, 0.62f); - pole.normVec(); - return pole; - } - - // Tanner Helland-style black-body color approximation from temperature in Kelvin. - // Returns sRGB 0..1. Accurate enough (within a few percent) for the visual range - // 1000K..40000K and matches the familiar red->white->blue stellar appearance. - LLColor3 blackBodyColor(F32 temperature_K) - { - const F32 t = temperature_K * 0.01f; // temperature in hundreds of K - - F32 r, g, b; - - if (t <= 66.0f) - { - r = 1.0f; - } - else - { - const F32 x = t - 60.0f; - r = 329.698727446f * powf(x, -0.1332047592f) / 255.0f; - } - - if (t <= 66.0f) - { - g = (99.4708025861f * logf(llmax(1.0f, t)) - 161.1195681661f) / 255.0f; - } - else - { - const F32 x = t - 60.0f; - g = 288.1221695283f * powf(x, -0.0755148492f) / 255.0f; - } - - if (t >= 66.0f) - { - b = 1.0f; - } - else if (t <= 19.0f) - { - b = 0.0f; - } - else - { - b = (138.5177312231f * logf(t - 10.0f) - 305.0447927307f) / 255.0f; - } - - return LLColor3(llclamp(r, 0.0f, 1.0f), - llclamp(g, 0.0f, 1.0f), - llclamp(b, 0.0f, 1.0f)); - } - - // Sample a stellar temperature. The distribution roughly tracks stellar - // population statistics - dominated by cool M/K dwarfs with a long tail - // toward the blue. - template F32 sampleStellarTemperature(Rand& draw) - { - const F32 u = draw(); - if (u < 0.0025f) return 18000.0f + draw() * 22000.0f; // O/B class (rare, very blue) - if (u < 0.02f) return 10000.0f + draw() * 8000.0f; // late-B / A class (blue-white) - if (u < 0.10f) return 7000.0f + draw() * 3000.0f; // F class (yellow-white) - if (u < 0.30f) return 5500.0f + draw() * 1500.0f; // G class (sun-like) - if (u < 0.60f) return 4000.0f + draw() * 1500.0f; // K class (orange) - return 2600.0f + draw() * 1400.0f; // M class (cool red, majority) - } - - // Power-law luminosity, biased heavily toward the faint end. Returned - // value is normalized to [0..1]. Callers remap to the tier's intensity - // range so the per-tier distribution forms a plausible magnitude scale. - template F32 samplePowerLawIntensity(Rand& draw) - { - const F32 u = draw(); - // Exponent 4 is a rough visual match to the Salpeter-like tail: most - // stars are near the floor, a few outliers reach the ceiling. - return powf(u, 4.0f); - } - - // Uniformly sample a direction on the upper hemisphere (z >= 0). - template LLVector3 uniformUpperHemisphere(Rand& draw) - { - const F32 z = draw(); // area-weighted - const F32 phi = F_TWO_PI * draw(); - const F32 r = sqrtf(llmax(0.0f, 1.0f - z * z)); - return LLVector3(r * cosf(phi), r * sinf(phi), z); - } - // Pick a random unit vector perpendicular to `n`. Uses a deterministic // orthogonal basis built from `n` and a fixed "up" swap for robustness // at the poles, then rotates around `n` by a uniform angle. @@ -224,41 +68,14 @@ namespace const F32 rot = F_TWO_PI * draw(); return a * cosf(rot) + b * sinf(rot); } - - // Rejection-sampled direction with optional bias toward the galactic plane. - // A Gaussian density around cos(angle_from_plane)=0 yields a band a few - // tens of degrees wide, which reads as a believable Milky Way without - // relying on a texture. - template LLVector3 generateStarDirection(const LLVector3& galactic_pole, bool milky_way_biased, Rand& draw) - { - constexpr U32 MAX_ATTEMPTS = 24; - - for (U32 attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) - { - LLVector3 dir = uniformUpperHemisphere(draw); - - if (!milky_way_biased) - return dir; - - // cos(angle-to-pole) == 0 at the plane, == 1 at the pole. - const F32 cos_to_pole = fabsf(dir * galactic_pole); - // Gaussian density peaking at the plane. sigma ~ 18 degrees. - const F32 density = expf(-cos_to_pole * cos_to_pole * 10.0f); - if (draw() < density) - return dir; - } - - // Unlikely to ever hit this fallback, but keeps the function total. - return uniformUpperHemisphere(draw); - } } U32 LLVOWLSky::getStarsNumVerts(void) const { - // mStarPositions is the source of truth — its size reflects whatever + // mStars is the source of truth — its size reflects whatever // RenderStarCount asked for at the last initStars() call (after // proportional scaling and rounding). - return (U32)mStarPositions.size(); + return (U32)mStars.size(); } LLVOWLSky::LLVOWLSky(const LLUUID &id, const LLPCode pcode, LLViewerRegion *regionp) @@ -465,54 +282,11 @@ void LLVOWLSky::drawDome(void) void LLVOWLSky::initStars() { - const F32 DISTANCE_TO_STARS = LLEnvironment::instance().getCurrentSky()->getDomeRadius(); - const LLVector3 galactic_pole = getGalacticPole(); - - // Scale every tier by RenderStarCount / kDefaultStarTotal so the visual - // mix between bright primaries, mid stars, and faint dust stays the same - // when the user dials the total down for performance. + // RenderStarCount scales every tier alike, so the mix of bright primaries, mid stars and faint dust stays the same + // when the total is dialled down for performance. const S32 requested = gSavedSettings.getS32("RenderStarCount"); - const U32 target_total = (U32)llclamp(requested, 0, (S32)kMaxStarTotal); - const F32 scale = (F32)target_total / (F32)kDefaultStarTotal; - - mStarPositions.clear(); - mStarColors.clear(); - mStarPositions.reserve(target_total); - mStarColors.reserve(target_total); - - FieldRandom field; - for (const StarTier& tier : kStarTiers) - { - const F32 tier_radius = DISTANCE_TO_STARS * tier.distance_factor; - const U32 tier_count = (U32)((F32)tier.count * scale + 0.5f); - - for (U32 i = 0; i < tier_count; ++i) - { - StarRandom draw(field()); - const bool in_milky_way = draw() < tier.milky_way_fraction; - LLVector3 dir = generateStarDirection(galactic_pole, in_milky_way, draw); - - mStarPositions.push_back(dir * tier_radius); - - // Distance-tier dimming is baked into intensity_floor/ceil so the - // far "dust" tier naturally renders faint. We only remap the - // power-law sample into that range here. - const F32 raw = samplePowerLawIntensity(draw); - const F32 intensity = llclamp( - tier.intensity_floor + raw * (tier.intensity_ceil - tier.intensity_floor), - 0.0f, 1.0f); - - const F32 temperature_K = sampleStellarTemperature(draw); - const LLColor3 bb = blackBodyColor(temperature_K); - - LLColor4 c; - c.mV[VRED] = bb.mV[VRED]; - c.mV[VGREEN] = bb.mV[VGREEN]; - c.mV[VBLUE] = bb.mV[VBLUE]; - c.mV[VALPHA] = intensity; - mStarColors.push_back(c); - } - } + mStars = ALStarField::generate((U32)llmax(requested, 0), + LLEnvironment::instance().getCurrentSky()->getDomeRadius()); } bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) @@ -557,11 +331,6 @@ bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) LL_ERRS() << "Failed updating star geometry." << LL_ENDL; } - if (mStarPositions.size() < num_stars || mStarColors.size() < num_stars) - { - LL_ERRS() << "Star reference geometry insufficient." << LL_ENDL; - } - // Two-triangle quad. Corner offsets are in [-1, 1] — the vertex shader // turns them into pixel-sized screen-space offsets, so the actual CPU // "position" of every vertex of a star is identical (the star center). @@ -578,13 +347,12 @@ bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) for (U32 s = 0; s < num_stars; ++s) { - const LLVector3& center = mStarPositions[s]; - const LLColor4U color_u(mStarColors[s]); + const ALStarField::Star& star = mStars[s]; for (U32 v = 0; v < 6; ++v) { - *(verticesp++) = center; - *(colorsp++) = color_u; + *(verticesp++) = star.mPosition; + *(colorsp++) = star.mColour; *(texcoordsp++) = kCornerOffsets[v]; } } @@ -623,7 +391,7 @@ void LLVOWLSky::tickMeteors(F32 dt_seconds) // Start point anywhere on the upper hemisphere, but prefer a bit above // the horizon so meteors don't immediately pop out of view. - LLVector3 start_dir = uniformUpperHemisphere(globalRandom); + LLVector3 start_dir = ALStarField::uniformUpperHemisphere(globalRandom); // Lift horizon-hugging starts slightly so short streaks stay visible. if (start_dir.mV[2] < 0.1f) { @@ -656,7 +424,7 @@ void LLVOWLSky::tickMeteors(F32 dt_seconds) peak = 0.35f + 0.25f * ll_frand(); life_mult = 0.75f + 0.25f * ll_frand(); const F32 tk = 2500.0f + 1500.0f * ll_frand(); - color = blackBodyColor(tk); + color = ALStarField::blackBodyColor(tk); } else if (roll < 0.85f) { @@ -664,7 +432,7 @@ void LLVOWLSky::tickMeteors(F32 dt_seconds) width_scale = 0.85f + 0.35f * ll_frand(); peak = 0.6f + 0.3f * ll_frand(); const F32 tk = 4500.0f + 2500.0f * ll_frand(); - color = blackBodyColor(tk); + color = ALStarField::blackBodyColor(tk); } else if (roll < 0.96f) { @@ -675,7 +443,7 @@ void LLVOWLSky::tickMeteors(F32 dt_seconds) path_mult = 1.25f + 0.35f * ll_frand(); life_mult = 0.6f + 0.25f * ll_frand(); const F32 tk = 8000.0f + 7000.0f * ll_frand(); - color = blackBodyColor(tk); + color = ALStarField::blackBodyColor(tk); } else { @@ -696,7 +464,7 @@ void LLVOWLSky::tickMeteors(F32 dt_seconds) { // Hot white bolide, no exotic chemistry. const F32 tk = 9000.0f + 6000.0f * ll_frand(); - color = blackBodyColor(tk); + color = ALStarField::blackBodyColor(tk); } } diff --git a/indra/newview/llvowlsky.h b/indra/newview/llvowlsky.h index d5fdb0c52f..9fde5a1e30 100644 --- a/indra/newview/llvowlsky.h +++ b/indra/newview/llvowlsky.h @@ -32,6 +32,8 @@ #include "llviewerobject.h" +#include "alstarfield.h" + #include class LLVOWLSky : public LLStaticViewerObject { @@ -73,9 +75,8 @@ class LLVOWLSky : public LLStaticViewerObject { LLPointer mStarsVerts; LLPointer mMeteorVerts; - // Per-star data. All three vectors stay in lock-step. - std::vector mStarPositions; // Star centers (world-space, relative to camera) - std::vector mStarColors; // RGB = black-body color (sRGB 0..1), A = intrinsic intensity (0..1) + // The star field RenderStarCount asked for, in the order the buffer takes it. + std::vector mStars; struct MeteorState { From 826d8c1d4040adaa1988b9005b2204ef2d79f8f1 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 04:26:43 -0400 Subject: [PATCH 2/7] Work every per-star term of the stars once a vertex starsF ran, for every fragment of every star's quad, five value noises (forty hashes), the Kasten-Young airmass (an asin and a pow), the moon's glare and wash, and the moon mask's texture tap, though every one of them reads only the star's direction, which is the same at all six of its vertices. They move to starsV: the extinction, the scintillation and its colour jitter, the moon's glare and wash, the subpixel fade and both paths' gains fold into one flat colour, vary_star_color, and the spikes' strength into vary_spike. The fragment stage keeps only the shape (core, halo and spikes) times that colour. The moon's mask was a per-fragment discard of a per-star test, so it always took a star whole; starsV now leaves such a star out before it is rasterized, by placing it outside the clip volume, and reads the moon's texture with textureLod as starsF did. alstarfield_gl_test now bounds each pixel of the stars' attachment by one step of its storage (8 bits, or R11G11B10F's mantissa at the pixel's value) plus half a step of 8 bits, and reports each scene. Here 0 to 5 pixels of 15k to 133k lit ones differ per scene and path, each by one storage step, where the reordered arithmetic rounds the other way; nothing passes the bound. With the vertex stage's moon cull taken out, both moonlit scenes fail on both paths (up to 128/255 over 927 to 1458 pixels). glslangValidator -l links all four programs (emissive and not, reverse-Z and not) with their attached objects; a control that renames a varying fails all four. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/tests/alstarfield_gl_test.cpp | 39 +++- .../shaders/class1/deferred/starsF.glsl | 154 +-------------- .../shaders/class1/deferred/starsV.glsl | 178 ++++++++++++++++-- 3 files changed, 209 insertions(+), 162 deletions(-) diff --git a/indra/alrender/tests/alstarfield_gl_test.cpp b/indra/alrender/tests/alstarfield_gl_test.cpp index ec9cedbf52..83fa50e88c 100644 --- a/indra/alrender/tests/alstarfield_gl_test.cpp +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -44,6 +44,7 @@ #include "../../llrender/tests/llheadlessgl_fixture.h" #include +#include #include #include @@ -306,14 +307,38 @@ void main() const F32* pixel(S32 attachment, S32 x, S32 y) const { return &mAttachments[attachment][(size_t(y) * W + x) * 4]; } }; + // A step of the attachment's storage at @a value: a step of 8 bits, or of R11G11B10F's mantissa, six bits for red + // and green and five for blue, at value's exponent (its least, 2^-14, below it). + F32 storageStep(F32 value, bool emissive, S32 channel) + { + if (!emissive) + { + return 1.f / 255.f; + } + const S32 mantissa = channel == 2 ? 5 : 6; + S32 exponent = 0; + std::frexp(llmax(fabsf(value), 0x1p-14f), &exponent); + return std::ldexp(1.f, exponent - 1 - mantissa); + } + // How two pictures' star attachment differ, over the colour channels: the most, the pixels where it differs at - // all and where by half a step of 8 bits or more, and the pixels a star lit in either. + // all, by half a step of 8 bits or more, and by more than a step of the storage and half a step of 8 bits (a + // rounding the order of the maths may flip, and what the brightness cull may leave out), and the pixels a star lit + // in either. struct Difference { F32 mMost = 0.f; S32 mDiffer = 0; S32 mPastHalfQuantum = 0; + S32 mBeyond = 0; S32 mLit = 0; + + std::string summary() const + { + return fmt::format("most {:.6f} ({:.2f}/255), {} of {} lit pixels differ, {} by half a step of 8 bits or " + "more, {} past the bound", + mMost, mMost * 255.f, mDiffer, mLit, mPastHalfQuantum, mBeyond); + } }; struct alstarfield_gl_data @@ -638,6 +663,7 @@ void main() // @a blank. static Difference compare(const Picture& a, const Picture& b, const Picture& blank, S32 attachment) { + const bool emissive = attachment == 3; Difference d; for (S32 y = 0; y < H; ++y) { @@ -648,14 +674,18 @@ void main() const F32* pn = blank.pixel(attachment, x, y); F32 most = 0.f; bool lit = false; + bool beyond = false; for (S32 c = 0; c < 3; ++c) { - most = llmax(most, fabsf(pa[c] - pb[c])); + const F32 diff = fabsf(pa[c] - pb[c]); + most = llmax(most, diff); lit = lit || pa[c] != pn[c] || pb[c] != pn[c]; + beyond = beyond || diff > storageStep(llmax(pa[c], pb[c]), emissive, c) + HALF_QUANTUM; } d.mMost = llmax(d.mMost, most); d.mDiffer += most > 0.f; d.mPastHalfQuantum += most >= HALF_QUANTUM; + d.mBeyond += beyond; d.mLit += lit; } } @@ -689,10 +719,11 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the pictu const Picture before = drawOld(scene, emissive); const Picture after = drawStars(scene, emissive); const Difference d = compare(before, after, blank, attachment); - CAPTURE(d.mMost, d.mDiffer, d.mPastHalfQuantum, d.mLit); + const std::string summary = d.summary(); + CAPTURE(summary); UNSCOPED_INFO("the scene shows stars"); CHECK(d.mLit > 100); - CHECK(d.mMost < HALF_QUANTUM); + CHECK(d.mBeyond == 0); if (scene.mMoonBrightness > 0.f) { const Difference moon = compare(before, drawOld(scene, emissive, false), blank, attachment); diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl index 84bd8c7186..fcc3618c6b 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl @@ -30,64 +30,9 @@ out vec4 frag_data[4]; -in vec3 vary_world_dir; // normalized star direction in (rotated) sky local space -in vec2 vary_corner; // corner offset in [-1, 1] — radial mask source -in vec4 vary_color; // rgb = linear BB color, a = intensity -in float vary_intensity; -in float vary_subpixel_fade; // energy attenuation for stars widened for sub-pixel stability - -uniform float custom_alpha; // star_brightness slider output (0..2 range in practice) -uniform float time; // seconds, wrapped to avoid precision drift -uniform vec3 moon_dir; // moon direction in the stars' frame, as vary_world_dir (z up) -uniform float moon_brightness;// moon illumination 0..1 (from LLSettingsSky) - -// The moon's quad in the stars' frame, on the plane a unit away along moonQuadCenter (zero when no moon is drawn), and -// its texture (LLDrawPoolWLSky::renderStarsDeferred). -uniform vec3 moonQuadCenter; -uniform vec3 moonQuadU; -uniform vec3 moonQuadV; -uniform sampler2D diffuseMap; - -// 3D value noise -// ------------------------------------------------------------------- -float hash31(vec3 p) -{ - p = fract(p * vec3(0.1031, 0.1030, 0.0973)); - p += dot(p, p.yzx + 33.33); - return fract((p.x + p.y) * p.z); -} - -float valueNoise3D(vec3 p) -{ - vec3 i = floor(p); - vec3 f = fract(p); - vec3 u = f * f * (3.0 - 2.0 * f); - - float n000 = hash31(i + vec3(0.0, 0.0, 0.0)); - float n100 = hash31(i + vec3(1.0, 0.0, 0.0)); - float n010 = hash31(i + vec3(0.0, 1.0, 0.0)); - float n110 = hash31(i + vec3(1.0, 1.0, 0.0)); - float n001 = hash31(i + vec3(0.0, 0.0, 1.0)); - float n101 = hash31(i + vec3(1.0, 0.0, 1.0)); - float n011 = hash31(i + vec3(0.0, 1.0, 1.0)); - float n111 = hash31(i + vec3(1.0, 1.0, 1.0)); - - return mix(mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y), - mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y), - u.z); -} - -// Kasten-Young airmass. cos_zenith is the cosine of the zenith angle (z -// component of the world-up aligned direction). Clamped to avoid the -// horizon singularity. -float airmassKastenYoung(float cos_zenith) -{ - float cz = clamp(cos_zenith, 0.02, 1.0); - // Kasten & Young (1989): X = 1 / (cos(z) + 0.50572 * (96.07995 - z_deg)^-1.6364) - // Rewritten using the elevation angle in degrees. - float elev_deg = degrees(asin(cz)); - return 1.0 / (cz + 0.50572 * pow(max(6.07995 + elev_deg, 1.0), -1.6364)); -} +in vec2 vary_corner; // corner offset in [-1, 1] — radial mask source +flat in vec3 vary_star_color; // the star's colour where its shape is 1 (starsV.glsl) +flat in float vary_spike; // the diffraction spikes' strength void main() { @@ -98,15 +43,12 @@ void main() float core = exp(-r2 * 9.0); float halo = exp(-r2 * 1.8) * 0.06; - // Diffraction spikes on the brightest tail only. Gated tight (0.90..1.0) - // so only real stand-out stars get crosses — the matching vertex shader - // also expands those stars' quads, so the spike has room to render. + // Diffraction spikes on the brightest tail only (starsV.glsl). float ax = 1.0 - abs(vary_corner.x); float ay = 1.0 - abs(vary_corner.y); float spike_h = exp(-abs(vary_corner.y) * 32.0) * ax; float spike_v = exp(-abs(vary_corner.x) * 32.0) * ay; - float spike_strength = smoothstep(0.90, 1.0, vary_intensity); - float spike = (spike_h + spike_v) * spike_strength * 0.30; + float spike = (spike_h + spike_v) * vary_spike * 0.30; float shape = core + halo + spike; @@ -114,87 +56,9 @@ void main() // the overdraw budget in check given how many tiny quads we render. if (shape < 0.002) discard; - // SL-14113: no star behind the moon. The moon draws after the stars and fades low in the sky, which would let them - // through its disc, so they leave out every texel moonF.glsl draws, taken from the moon's own quad and texture. - float moon_facing = dot(vary_world_dir, moonQuadCenter); - if (moon_facing > 0.0) - { - vec3 on_quad = vary_world_dir / moon_facing - moonQuadCenter; - vec2 quad = vec2(dot(on_quad, moonQuadU), dot(on_quad, moonQuadV)); - // textureLod: no derivatives inside this branch. - if (all(lessThan(abs(quad), vec2(1.0))) && textureLod(diffuseMap, quad * 0.5 + 0.5, 0.0).a > 2./255.) - { - discard; - } - } - - // --- Atmospheric extinction ---------------------------------------------- - // Airmass grows sharply near the horizon. The wavelength-dependent - // extinction (Rayleigh-dominated) reddens low stars, matching the familiar - // deep-orange appearance of rising/setting bright stars. - float cos_zenith = vary_world_dir.z; // +Z is up in sky local space - float airmass = airmassKastenYoung(cos_zenith); - - // Extinction coefficients tuned for a clear night (blue scatters ~5x more - // than red). Multiplied by a small overall factor so the zenith is nearly - // transparent while the horizon meaningfully dims. - const vec3 k_ext = vec3(0.05, 0.13, 0.30); - vec3 extinction = exp(-airmass * k_ext); - - // --- Scintillation ------------------------------------------------------- - // Per-star seed via the full 3D direction so nearby stars with similar xy - // but different elevations don't share the same twinkle pattern. Two - // noise samples at different frequencies give an organic beat instead of - // a periodic pulse. - vec3 seed = vary_world_dir * 120.0; - - float n_fast = valueNoise3D(seed + vec3(0.0, 0.0, time * 2.3)); - float n_slow = valueNoise3D(seed * 0.37 + vec3(0.0, 0.0, time * 0.7)); - float twinkle_base = mix(n_fast, n_slow, 0.35); - - // Scintillation is much stronger near the horizon (airmass) and for the - // brighter stars our eye actually resolves flickering on. - float twinkle_amount = clamp(0.18 + 0.14 * (airmass - 1.0), 0.0, 0.65) * vary_intensity; - float twinkle = 1.0 + (twinkle_base - 0.5) * 2.0 * twinkle_amount; - - // Subtle color scintillation: each channel samples its own noise with a - // phase shift, so the twinkle occasionally tints the star. - vec3 chroma_noise = vec3( - valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.7 + 0.0)), - valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.3 + 5.7)), - valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 2.1 + 13.1))); - float chroma_amt = 0.18 * twinkle_amount; - vec3 color_jitter = 1.0 + (chroma_noise - 0.5) * 2.0 * chroma_amt; - - // --- Moon glare ---------------------------------------------------------- - // Two contributions: a broad "sky wash" that dims faint stars when a bright - // moon is up (scattered moonlight raising the night-sky floor), and a - // narrow direct glare around the moon itself that kills anything nearby. - // Bright stars are almost immune to sky wash (they still outshine the - // scattered background), but nothing survives direct glare at a full moon. - float moon_up = max(0.0, moon_dir.z); // horizon clip - float moon_power = moon_brightness * moon_up; // 0..~1 - float sky_wash = moon_power * (1.0 - vary_intensity) * 0.55; - float moon_cos = max(0.0, dot(vary_world_dir, moon_dir)); - float direct_glare = pow(moon_cos, 10.0) * moon_power * 0.85; - float moon_fade = clamp(sky_wash + direct_glare, 0.0, 0.95); - - // --- Composition --------------------------------------------------------- - vec3 star_color = vary_color.rgb * color_jitter * extinction * twinkle; - star_color *= vary_intensity; - star_color *= custom_alpha; - star_color *= (1.0 - moon_fade); - star_color *= vary_subpixel_fade; - // Shape is our final alpha mask. Pre-multiply RGB so we can blend additively // from the draw pool without alpha weirdness. - vec3 premult = star_color * shape; - - // HDR boost for the emissive/bloom buffer: bright stars should feed the - // bloom pyramid with a larger signal than their on-screen dot suggests. - // Kept modest so the bloom pass doesn't smear every mid-tier star into a - // halo blob. - float bloom_boost = 1.0 + vary_intensity * vary_intensity * 1.2; + vec3 premult = vary_star_color * shape; // GBuffer: stars opt out of atmospheric haze (SKIP_ATMOS). frag_data[1] = vec4(0.0); @@ -204,10 +68,8 @@ void main() // With a dedicated emissive buffer the stars live there (bloom source); // main diffuse stays zero so atmospheric haze doesn't pick them up. frag_data[0] = vec4(0.0); - frag_data[3] = vec4(premult * bloom_boost, shape); + frag_data[3] = vec4(premult, shape); #else - // No emissive buffer - write to diffuse. The glow extraction still catches - // the bright peaks via thresholding, so scale slightly up to compensate. - frag_data[0] = vec4(premult * (1.0 + vary_intensity), shape); + frag_data[0] = vec4(premult, shape); #endif } diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl index ffbe156359..0eb6c78ee9 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl @@ -35,11 +35,21 @@ in vec3 position; // star center world position (shared by all 6 verts of in vec4 diffuse_color; // RGB = sRGB black-body color, A = intrinsic intensity (0..1) in vec2 texcoord0; // corner offset in [-1, 1] for GPU-side billboarding -out vec3 vary_world_dir; // direction from camera to star in the stars' frame, before their slow rotation about z -out vec2 vary_corner; // corner offset, pass-through for fragment shape -out vec4 vary_color; // rgb = linear BB color, a = intensity -out float vary_intensity; -out float vary_subpixel_fade; // energy attenuation from sub-pixel widening (1.0 = no widening) +out vec2 vary_corner; // corner offset, pass-through for fragment shape +flat out vec3 vary_star_color; // what the star adds where its shape is 1: every per-star term but the shape +flat out float vary_spike; // the diffraction spikes' strength + +uniform float custom_alpha; // star_brightness slider output (0..2 range in practice) +uniform float time; // seconds, wrapped to avoid precision drift +uniform vec3 moon_dir; // moon direction in the stars' frame (z up) +uniform float moon_brightness; // moon illumination 0..1 (from LLSettingsSky) + +// The moon's quad in the stars' frame, on the plane a unit away along moonQuadCenter (zero when no moon is drawn), and +// its texture (LLDrawPoolWLSky::renderStarsDeferred). +uniform vec3 moonQuadCenter; +uniform vec3 moonQuadU; +uniform vec3 moonQuadV; +uniform sampler2D diffuseMap; // sRGB -> linear approximation; good enough for stars since BB color is smooth. vec3 srgb_to_linear(vec3 c) @@ -47,8 +57,77 @@ vec3 srgb_to_linear(vec3 c) return pow(max(c, vec3(0.0)), vec3(2.2)); } +// 3D value noise +// ------------------------------------------------------------------- +float hash31(vec3 p) +{ + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yzx + 33.33); + return fract((p.x + p.y) * p.z); +} + +float valueNoise3D(vec3 p) +{ + vec3 i = floor(p); + vec3 f = fract(p); + vec3 u = f * f * (3.0 - 2.0 * f); + + float n000 = hash31(i + vec3(0.0, 0.0, 0.0)); + float n100 = hash31(i + vec3(1.0, 0.0, 0.0)); + float n010 = hash31(i + vec3(0.0, 1.0, 0.0)); + float n110 = hash31(i + vec3(1.0, 1.0, 0.0)); + float n001 = hash31(i + vec3(0.0, 0.0, 1.0)); + float n101 = hash31(i + vec3(1.0, 0.0, 1.0)); + float n011 = hash31(i + vec3(0.0, 1.0, 1.0)); + float n111 = hash31(i + vec3(1.0, 1.0, 1.0)); + + return mix(mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y), + mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y), + u.z); +} + +// Kasten-Young airmass. cos_zenith is the cosine of the zenith angle (z +// component of the world-up aligned direction). Clamped to avoid the +// horizon singularity. +float airmassKastenYoung(float cos_zenith) +{ + float cz = clamp(cos_zenith, 0.02, 1.0); + // Kasten & Young (1989): X = 1 / (cos(z) + 0.50572 * (96.07995 - z_deg)^-1.6364) + // Rewritten using the elevation angle in degrees. + float elev_deg = degrees(asin(cz)); + return 1.0 / (cz + 0.50572 * pow(max(6.07995 + elev_deg, 1.0), -1.6364)); +} + +// SL-14113: no star behind the moon. The moon draws after the stars and fades low in the sky, which would let them +// through its disc, so a star whose direction falls on a texel moonF.glsl draws, taken from the moon's own quad and +// texture, is left out whole. +bool behindMoon(vec3 dir) +{ + float moon_facing = dot(dir, moonQuadCenter); + if (moon_facing > 0.0) + { + vec3 on_quad = dir / moon_facing - moonQuadCenter; + vec2 quad = vec2(dot(on_quad, moonQuadU), dot(on_quad, moonQuadV)); + return all(lessThan(abs(quad), vec2(1.0))) && textureLod(diffuseMap, quad * 0.5 + 0.5, 0.0).a > 2./255.; + } + return false; +} + void main() { + // Every term but the shape depends only on the star, so it is worked here, once a vertex, rather than once a + // fragment, and the fragment stage multiplies the shape by it. + vec3 dir = normalize(position); + if (behindMoon(dir)) + { + // Outside the clip volume: nothing of the star is drawn. + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + vary_corner = vec2(0.0); + vary_star_color = vec3(0.0); + vary_spike = 0.0; + return; + } + // Transform the star center to clip space. All 6 verts of a star share // the same input position, so the center projects identically; the only // per-vertex change is the screen-space corner offset we add below. @@ -70,9 +149,9 @@ void main() // and compensate by scaling output energy by the area ratio — integrated // brightness stays the same, but the distribution is smooth enough for // the pixel grid to resolve stably. - const float min_px = 2.6; - float quad_pixels = max(ideal_px, min_px); - vary_subpixel_fade = (ideal_px * ideal_px) / (quad_pixels * quad_pixels); + const float min_px = 2.6; + float quad_pixels = max(ideal_px, min_px); + float subpixel_fade = (ideal_px * ideal_px) / (quad_pixels * quad_pixels); vec2 pixel_offset = texcoord0 * (quad_pixels * 0.5); @@ -91,15 +170,90 @@ void main() #endif gl_Position = clip_center; + vary_corner = texcoord0; - vary_corner = texcoord0; - vary_world_dir = normalize(position); // NB: this linearises the sRGB black-body colour, and softenLight's SKIP_ATMOS branch // then srgb_to_linear's it a SECOND time and multiplies by sky_hdr_scale (the sky dome's // fake-HDR boost, ~2x with HDR on -- stars inherit it via the shared SKIP_ATMOS flag). // Long-standing upstream behaviour. Net star brightness balances that double-decode // against the gains here (bloom_boost, the (1 + intensity) gain) and sky_hdr_scale -- if // star brightness is ever retuned, this whole chain is the place to look, not one line. - vary_color = vec4(srgb_to_linear(diffuse_color.rgb), intensity); - vary_intensity = intensity; + vec3 color = srgb_to_linear(diffuse_color.rgb); + + // --- Atmospheric extinction ---------------------------------------------- + // Airmass grows sharply near the horizon. The wavelength-dependent + // extinction (Rayleigh-dominated) reddens low stars, matching the familiar + // deep-orange appearance of rising/setting bright stars. + float cos_zenith = dir.z; // +Z is up in sky local space + float airmass = airmassKastenYoung(cos_zenith); + + // Extinction coefficients tuned for a clear night (blue scatters ~5x more + // than red). Multiplied by a small overall factor so the zenith is nearly + // transparent while the horizon meaningfully dims. + const vec3 k_ext = vec3(0.05, 0.13, 0.30); + vec3 extinction = exp(-airmass * k_ext); + + // --- Scintillation ------------------------------------------------------- + // Per-star seed via the full 3D direction so nearby stars with similar xy + // but different elevations don't share the same twinkle pattern. Two + // noise samples at different frequencies give an organic beat instead of + // a periodic pulse. + vec3 seed = dir * 120.0; + + float n_fast = valueNoise3D(seed + vec3(0.0, 0.0, time * 2.3)); + float n_slow = valueNoise3D(seed * 0.37 + vec3(0.0, 0.0, time * 0.7)); + float twinkle_base = mix(n_fast, n_slow, 0.35); + + // Scintillation is much stronger near the horizon (airmass) and for the + // brighter stars our eye actually resolves flickering on. + float twinkle_amount = clamp(0.18 + 0.14 * (airmass - 1.0), 0.0, 0.65) * intensity; + float twinkle = 1.0 + (twinkle_base - 0.5) * 2.0 * twinkle_amount; + + // Subtle color scintillation: each channel samples its own noise with a + // phase shift, so the twinkle occasionally tints the star. + vec3 chroma_noise = vec3( + valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.7 + 0.0)), + valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.3 + 5.7)), + valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 2.1 + 13.1))); + float chroma_amt = 0.18 * twinkle_amount; + vec3 color_jitter = 1.0 + (chroma_noise - 0.5) * 2.0 * chroma_amt; + + // --- Moon glare ---------------------------------------------------------- + // Two contributions: a broad "sky wash" that dims faint stars when a bright + // moon is up (scattered moonlight raising the night-sky floor), and a + // narrow direct glare around the moon itself that kills anything nearby. + // Bright stars are almost immune to sky wash (they still outshine the + // scattered background), but nothing survives direct glare at a full moon. + float moon_up = max(0.0, moon_dir.z); // horizon clip + float moon_power = moon_brightness * moon_up; // 0..~1 + float sky_wash = moon_power * (1.0 - intensity) * 0.55; + float moon_cos = max(0.0, dot(dir, moon_dir)); + float direct_glare = pow(moon_cos, 10.0) * moon_power * 0.85; + float moon_fade = clamp(sky_wash + direct_glare, 0.0, 0.95); + + // --- Composition --------------------------------------------------------- + vec3 star_color = color * color_jitter * extinction * twinkle; + star_color *= intensity; + star_color *= custom_alpha; + star_color *= (1.0 - moon_fade); + star_color *= subpixel_fade; + +#if defined(HAS_EMISSIVE) + // HDR boost for the emissive/bloom buffer: bright stars should feed the + // bloom pyramid with a larger signal than their on-screen dot suggests. + // Kept modest so the bloom pass doesn't smear every mid-tier star into a + // halo blob. + star_color *= 1.0 + intensity * intensity * 1.2; +#else + // No emissive buffer - the stars go to diffuse. The glow extraction still catches + // the bright peaks via thresholding, so scale slightly up to compensate. + star_color *= 1.0 + intensity; +#endif + + vary_star_color = star_color; + + // Diffraction spikes on the brightest tail only. Gated tight (0.90..1.0) + // so only real stand-out stars get crosses — the quad expands above for + // those stars, so the spike has room to render. + vary_spike = smoothstep(0.90, 1.0, intensity); } From 8248f131dd33d5fbec62d56796429811d8e26f45 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 04:40:30 -0400 Subject: [PATCH 3/7] Draw each star as one point, and the spiked stars as quads Every star was a quad of six non-indexed vertices of 28 bytes each: 155k stars made 930k vertices and 26 MB. Now a star is one GL_POINTS vertex of 16 bytes: its centre in xyz and its colour's four bytes in the position's fourth word, where LLVertexBuffer keeps the texture index (MAP_VERTEX | MAP_TEXTURE_INDEX), unpacked by unpackUnorm4x8 in starsV. The default field's buffer is 2.5 MB. GL drops a point whose centre leaves the screen, which would pop the arms of the stars with diffraction spikes (up to 19 pixels across) at the edge, so those draw as quads in a second, small draw (D4): the 295 stars of the default field whose intensity byte is 230 or more, the first past starsV's smoothstep(0.90, 1.0, intensity). ALStarField lays the buffer out with their quads first, six vertices each, from vertex 0, so the quad program (gDeferredStarQuadProgram, starsV and starsF under STAR_QUADS) takes each corner from gl_VertexID % 6; then the rest, one vertex each. A point is sized by gl_PointSize under GL_PROGRAM_POINT_SIZE, but GL rounds that size to its granularity (0.125 pixels on NVIDIA) and measures gl_PointCoord across the rounded square, which scaled a 2.6-pixel star's shape by up to 5% and, in the A/B below, changed more pixels than this does. So starsV passes the star's centre in window coordinates and half its extent, draws the point a pixel wider than the star, and starsF works each pixel's offset from the centre from gl_FragCoord and leaves out the pixels past the star's square: the same pixels as the quad and the same shape, whatever the driver's granularity. The viewport that maps the centre is the bound target's (LLRenderTarget::sCurResX/Y), which is also what now sizes the quads. alstarfield_test pins SPIKE_INTENSITY to starsV's gate for every intensity byte and checks that the layout puts every spiked star first, keeps the field's order within each kind, and writes each star's centre and colour bytes, six times for a quad and once for a point, and nothing past the end. The A/B against the old quads now measures what may differ by the old picture itself moved one step of the rasterizer's sub-pixel grid (1/256 of a pixel), since the old quads' corners are snapped to it where a point's centre is exact. The new picture differs from the old at 1.8 to 4.8 times fewer pixels than that moved picture does in every scene and path, by at most 3/255 on the albedo path (the moved old picture, 4/255) and, on the emissive path, by at most one R11G11B10F step more, which is the stars' blends rounding in a different order where they overlap (the old shaders in the new order alone give that). The plain gl_PointCoord form fails the A/B. glslangValidator -l links all eight programs (points and quads, emissive and not, reverse-Z and not); a control that renames the quads' corner output fails the four quad programs. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/alstarfield.cpp | 37 +++++++ indra/alrender/alstarfield.h | 36 ++++++ indra/alrender/tests/alstarfield_gl_test.cpp | 104 +++++++++++++----- indra/alrender/tests/alstarfield_test.cpp | 100 ++++++++++++++++- .../shaders/class1/deferred/starsF.glsl | 27 +++-- .../shaders/class1/deferred/starsV.glsl | 51 +++++++-- indra/newview/lldrawpoolwlsky.cpp | 85 ++++++++------ indra/newview/lldrawpoolwlsky.h | 3 +- indra/newview/llviewershadermgr.cpp | 17 +++ indra/newview/llviewershadermgr.h | 1 + indra/newview/llvowlsky.cpp | 70 ++++-------- indra/newview/llvowlsky.h | 10 +- 12 files changed, 404 insertions(+), 137 deletions(-) diff --git a/indra/alrender/alstarfield.cpp b/indra/alrender/alstarfield.cpp index 5f1d8b2410..1b8751f708 100644 --- a/indra/alrender/alstarfield.cpp +++ b/indra/alrender/alstarfield.cpp @@ -31,7 +31,9 @@ #include "v3color.h" #include "v4color.h" +#include #include +#include #include namespace @@ -106,4 +108,39 @@ std::vector generate(U32 count, F32 dome_radius) return detail::generate(count, dome_radius, [](const LLVector3& direction) { return milkyWayDensity(direction); }); } +Layout layOut(std::vector& stars) +{ + const auto points = std::stable_partition(stars.begin(), stars.end(), spiked); + + Layout layout; + layout.mQuads = (U32)(points - stars.begin()); + layout.mPoints = (U32)(stars.end() - points); + return layout; +} + +void fillVertices(const std::vector& stars, const Layout& layout, LLStrider vertices) +{ + llassert(stars.size() == (size_t)layout.mQuads + layout.mPoints); + + const auto put = [&vertices](const Star& star) + { + LLVector4a& vertex = *(vertices++); + vertex.load3(star.mPosition.mV); + // The colour's bytes as they are, never through a float: an F32 with these bits may be a NaN. + std::memcpy(reinterpret_cast(&vertex) + 3 * sizeof(F32), star.mColour.mV, sizeof(star.mColour.mV)); + }; + + for (U32 s = 0; s < layout.mQuads; ++s) + { + for (U32 v = 0; v < QUAD_VERTICES; ++v) + { + put(stars[s]); + } + } + for (U32 s = layout.mQuads; s < layout.mQuads + layout.mPoints; ++s) + { + put(stars[s]); + } +} + } // namespace ALStarField diff --git a/indra/alrender/alstarfield.h b/indra/alrender/alstarfield.h index 693906eca4..547c46e06f 100644 --- a/indra/alrender/alstarfield.h +++ b/indra/alrender/alstarfield.h @@ -25,6 +25,8 @@ #pragma once #include "llmath.h" +#include "llstrider.h" +#include "llvector4a.h" #include "stdtypes.h" #include "v3color.h" #include "v3math.h" @@ -84,4 +86,38 @@ F32 milkyWayDensity(const LLVector3& direction); /// differ, and no other does. std::vector generate(U32 count, F32 dome_radius); +/// The faintest intensity, as the colour's alpha holds it, that has diffraction spikes: starsV.glsl's +/// smoothstep(0.90, 1.0, intensity) is above 0 past 0.90, and 230 / 255 is the first step past it. Such a star reaches +/// up to 19 pixels across, and GL drops a point whose centre leaves the screen, so its arms would pop at the edge: it is +/// drawn as a quad instead. Every other star is a point, at most 7 pixels across. +constexpr U8 SPIKE_INTENSITY = 230; + +inline bool spiked(const Star& star) +{ + return star.mColour.mV[VALPHA] >= SPIKE_INTENSITY; +} + +/// The vertices of a spiked star's quad: two triangles, whose corners starsV.glsl takes from gl_VertexID, the quads +/// lying first in the buffer, from vertex 0. +constexpr U32 QUAD_VERTICES = 6; + +/// Where the stars lie in the buffer: the spiked stars' quads first, QUAD_VERTICES vertices each, then every other star, +/// one vertex each, drawn as points. +struct Layout +{ + U32 mQuads = 0; ///< spiked stars + U32 mPoints = 0; ///< the rest + + U32 firstPoint() const { return mQuads * QUAD_VERTICES; } + U32 vertices() const { return firstPoint() + mPoints; } +}; + +/// Orders @a stars as the buffer lays them out, and says where each kind lies. +Layout layOut(std::vector& stars); + +/// The buffer's vertices for @a stars laid out as @a layout, layout.vertices() of them: a star's centre in xyz and its +/// colour's four bytes in w, the word where LLVertexBuffer keeps the texture index, so a star is 16 bytes. A quad's +/// star is written QUAD_VERTICES times. +void fillVertices(const std::vector& stars, const Layout& layout, LLStrider vertices); + } // namespace ALStarField diff --git a/indra/alrender/tests/alstarfield_gl_test.cpp b/indra/alrender/tests/alstarfield_gl_test.cpp index 83fa50e88c..daa426692f 100644 --- a/indra/alrender/tests/alstarfield_gl_test.cpp +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -322,9 +322,8 @@ void main() } // How two pictures' star attachment differ, over the colour channels: the most, the pixels where it differs at - // all, by half a step of 8 bits or more, and by more than a step of the storage and half a step of 8 bits (a - // rounding the order of the maths may flip, and what the brightness cull may leave out), and the pixels a star lit - // in either. + // all, by half a step of 8 bits or more, and past the bound compare() is given, and the pixels a star lit in + // either. struct Difference { F32 mMost = 0.f; @@ -378,11 +377,13 @@ void main() for (S32 path = 0; path < 2; ++path) { mOld[path].unload(); - mStars[path].unload(); + mStarPoints[path].unload(); + mStarQuads[path].unload(); mTarget[path].release(); } mShaders.clearShaderObjects(); mOldQuads = nullptr; + mStarBuffer = nullptr; mMoonTexture = nullptr; gGL.matrixMode(LLRender::MM_PROJECTION); gGL.loadIdentity(); @@ -407,16 +408,20 @@ void main() REQUIRE(program.createShader()); } - // The star program as LLViewerShaderMgr builds it. - void buildStars(LLGLSLShader& program, bool emissive) + // A star program as LLViewerShaderMgr builds it: the points', or the spiked stars' quads'. + void buildStars(LLGLSLShader& program, bool emissive, bool quads) { - program.mName = "Deferred Star Program"; + program.mName = quads ? "Deferred Star Quad Program" : "Deferred Star Program"; program.mShaderFiles.clear(); program.mShaderFiles.push_back(std::make_pair("deferred/starsV.glsl", GL_VERTEX_SHADER)); program.mShaderFiles.push_back(std::make_pair("deferred/starsF.glsl", GL_FRAGMENT_SHADER)); program.mShaderLevel = 1; program.mShaderGroup = LLGLSLShader::SG_SKY; program.addConstant(LLGLSLShader::SHADER_CONST_STAR_DEPTH); + if (quads) + { + program.addPermutation("STAR_QUADS", "1"); + } if (emissive) { program.addPermutation("HAS_EMISSIVE", "1"); @@ -448,8 +453,8 @@ void main() } // The field at the default count; the old buffer, six vertices a star with their corner offsets, as - // LLVOWLSky::updateStarGeometry filled it; both paths' programs; and a G-buffer for each, the emissive one with - // four attachments and the albedo's with three. + // LLVOWLSky::updateStarGeometry filled it, and the buffer it fills now; both paths' programs; and a G-buffer + // for each, the emissive one with four attachments and the albedo's with three. void setUp() { populateTree(); @@ -489,12 +494,22 @@ void main() } mOldQuads->unmapBuffer(); + mLaidOut = mField; + mLayout = ALStarField::layOut(mLaidOut); + mStarBuffer = new LLVertexBuffer(LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_TEXTURE_INDEX); + REQUIRE(mStarBuffer->allocateBuffer(mLayout.vertices(), 0)); + LLStrider star_vertices; + REQUIRE(mStarBuffer->getVertexStrider(star_vertices)); + ALStarField::fillVertices(mLaidOut, mLayout, star_vertices); + mStarBuffer->unmapBuffer(); + mMoonTexture = moonDisc(); for (S32 path = 0; path < 2; ++path) { buildOld(mOld[path], path == 1); - buildStars(mStars[path], path == 1); + buildStars(mStarPoints[path], path == 1, false); + buildStars(mStarQuads[path], path == 1, true); REQUIRE(mTarget[path].allocate(W, H, GL_RGBA8)); REQUIRE(mTarget[path].addColorAttachment(GL_RGBA8)); REQUIRE(mTarget[path].addColorAttachment(GL_RGB10_A2)); @@ -505,8 +520,9 @@ void main() } } - // The camera looking along @a scene from CAMERA: the agent's frame (east, north, up) turned to the eye's. - static void loadView(const Scene& scene) + // The camera looking along @a scene from CAMERA: the agent's frame (east, north, up) turned to the eye's, the + // picture moved @a shift pixels right and half that up. + static void loadView(const Scene& scene, F32 shift = 0.f) { gGL.matrixMode(LLRender::MM_PROJECTION); const F32 tan_half = tanf(scene.mFov * 0.5f * DEG_TO_RAD); @@ -518,6 +534,8 @@ void main() p[10] = (FAR_CLIP + NEAR_CLIP) / (NEAR_CLIP - FAR_CLIP); p[11] = -1.f; p[14] = 2.f * FAR_CLIP * NEAR_CLIP / (NEAR_CLIP - FAR_CLIP); + p[8] = -shift * 2.f / (F32)W; + p[9] = -shift / (F32)H; gGL.loadMatrix(proj); gGL.matrixMode(LLRender::MM_MODELVIEW); gGL.loadIdentity(); @@ -588,13 +606,13 @@ void main() } // The stars drawn the old way, every star a quad of six vertices, into the G-buffer of @a emissive's path; the - // moon's mask only where @a mask_moon. - Picture drawOld(const Scene& scene, bool emissive, bool mask_moon = true) + // moon's mask only where @a mask_moon, the picture moved @a shift pixels. + Picture drawOld(const Scene& scene, bool emissive, bool mask_moon = true, F32 shift = 0.f) { LLRenderTarget& target = mTarget[emissive]; target.bindTarget(); clearDome(emissive); - loadView(scene); + loadView(scene, shift); { LLGLEnable blend(GL_BLEND); LLGLDisable cull(GL_CULL_FACE); @@ -634,12 +652,21 @@ void main() gGL.pushMatrix(); gGL.translatef(CAMERA.mV[VX], CAMERA.mV[VY], CAMERA.mV[VZ]); gGL.rotatef(scene.mRotation, 0.f, 0.f, 1.f); - LLGLSLShader& program = mStars[emissive]; - program.bind(); - bindUniforms(program, scene); - mOldQuads->setBuffer(); - mOldQuads->drawArrays(LLRender::TRIANGLES, 0, mOldQuads->getNumVerts()); - program.unbind(); + // The spiked stars as quads, then the rest as points. + LLGLSLShader& quads = mStarQuads[emissive]; + quads.bind(); + bindUniforms(quads, scene); + mStarBuffer->setBuffer(); + mStarBuffer->drawArrays(LLRender::TRIANGLES, 0, mLayout.firstPoint()); + quads.unbind(); + + LLGLEnable point_size(GL_PROGRAM_POINT_SIZE); + LLGLSLShader& points = mStarPoints[emissive]; + points.bind(); + bindUniforms(points, scene); + mStarBuffer->setBuffer(); + mStarBuffer->drawArrays(LLRender::POINTS, mLayout.firstPoint(), mLayout.mPoints); + points.unbind(); gGL.popMatrix(); gGL.setSceneBlendType(LLRender::BT_ALPHA); } @@ -660,8 +687,11 @@ void main() } // How the stars' attachment of two pictures differ, a star lighting a pixel where either differs from - // @a blank. - static Difference compare(const Picture& a, const Picture& b, const Picture& blank, S32 attachment) + // @a blank. Each channel of a pixel may differ by a step of its storage (where an addition rounds the other + // way: the order of the maths, or of the stars' blends), half a step of 8 bits (what the brightness cull may + // leave out of it) and @a allowance. + static Difference compare(const Picture& a, const Picture& b, const Picture& blank, S32 attachment, + F32 allowance = 0.f) { const bool emissive = attachment == 3; Difference d; @@ -680,7 +710,7 @@ void main() const F32 diff = fabsf(pa[c] - pb[c]); most = llmax(most, diff); lit = lit || pa[c] != pn[c] || pb[c] != pn[c]; - beyond = beyond || diff > storageStep(llmax(pa[c], pb[c]), emissive, c) + HALF_QUANTUM; + beyond = beyond || diff > storageStep(llmax(pa[c], pb[c]), emissive, c) + HALF_QUANTUM + allowance; } d.mMost = llmax(d.mMost, most); d.mDiffer += most > 0.f; @@ -696,10 +726,14 @@ void main() ll_test::TestShaderDir mTree; ll_test::TestShaderMgr mShaders; std::vector mField; + std::vector mLaidOut; + ALStarField::Layout mLayout; LLPointer mOldQuads; + LLPointer mStarBuffer; LLPointer mMoonTexture; LLGLSLShader mOld[2]; - LLGLSLShader mStars[2]; + LLGLSLShader mStarPoints[2]; + LLGLSLShader mStarQuads[2]; LLRenderTarget mTarget[2]; }; } @@ -707,6 +741,12 @@ void main() TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the picture they left before their rework", "[alstarfield_gl]") { + // The old quads' corners are snapped to the rasterizer's sub-pixel grid, 1/256 of a pixel, where a point's centre + // is worked exactly; so the old picture moved by one step of that grid is the measure of what may differ. The new + // picture may differ from the old at no more pixels than that, and at no pixel by more than the most it moves any + // pixel, besides the roundings compare() allows. + constexpr F32 SUBPIXEL = 1.f / 256.f; + setUp(); for (const Scene& scene : SCENES) { @@ -715,15 +755,19 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the pictu const bool emissive = path == 1; const S32 attachment = emissive ? 3 : 0; CAPTURE(scene.mName, emissive); - const Picture blank = drawNothing(emissive); - const Picture before = drawOld(scene, emissive); - const Picture after = drawStars(scene, emissive); - const Difference d = compare(before, after, blank, attachment); + const Picture blank = drawNothing(emissive); + const Picture before = drawOld(scene, emissive); + const Picture shifted = drawOld(scene, emissive, true, SUBPIXEL); + const Picture after = drawStars(scene, emissive); + const Difference jitter = compare(before, shifted, blank, attachment); + const Difference d = compare(before, after, blank, attachment, jitter.mMost); const std::string summary = d.summary(); - CAPTURE(summary); + const std::string old_moved = jitter.summary(); + CAPTURE(summary, old_moved); UNSCOPED_INFO("the scene shows stars"); CHECK(d.mLit > 100); CHECK(d.mBeyond == 0); + CHECK(d.mDiffer <= jitter.mDiffer); if (scene.mMoonBrightness > 0.f) { const Difference moon = compare(before, drawOld(scene, emissive, false), blank, attachment); diff --git a/indra/alrender/tests/alstarfield_test.cpp b/indra/alrender/tests/alstarfield_test.cpp index cf127b1565..ce567e8613 100644 --- a/indra/alrender/tests/alstarfield_test.cpp +++ b/indra/alrender/tests/alstarfield_test.cpp @@ -1,6 +1,7 @@ /** * @file alstarfield_test.cpp - * @brief The star field: the same field from the same count in every session, laid out in its tiers. + * @brief The star field: the same field from the same count in every session, laid out in its tiers, and the + * buffer it is drawn from. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -32,8 +33,15 @@ #include "../alstarfield.h" #include "llrand.h" +#include "llstrider.h" +#include "llvector4a.h" #include +#include +#include +#include +#include +#include #include namespace @@ -54,6 +62,16 @@ namespace { return a.mPosition == b.mPosition && a.mColour == b.mColour; } + + // A shader's source, read from the viewer's shader tree so the test reads what the viewer compiles. + std::string shader_source(const std::string& rel) + { + const std::filesystem::path path = std::filesystem::path(AL_TEST_SHADER_DIR) / rel; + std::ifstream in(path); + std::stringstream buf; + buf << in.rdbuf(); + return buf.str(); + } } TEST_CASE("alstarfield: a count lays out the same field every time", "[alstarfield]") @@ -254,3 +272,83 @@ TEST_CASE("alstarfield: every star lies on the upper hemisphere in its tier's in first = end; } } + +TEST_CASE("alstarfield: a star is spiked exactly where starsV.glsl gives it spikes", "[alstarfield]") +{ + // starsV.glsl's gate, smoothstep(0.90, 1.0, intensity), on the intensity the vertex stage unpacks. + const std::string vertex = shader_source("class1/deferred/starsV.glsl"); + REQUIRE(vertex.find("smoothstep(0.90, 1.0, intensity)") != std::string::npos); + for (S32 byte = 0; byte < 256; ++byte) + { + const F32 intensity = (F32)byte / 255.f; + const F32 t = llclamp((intensity - 0.90f) / (1.0f - 0.90f), 0.f, 1.f); + const F32 spike = t * t * (3.f - 2.f * t); + ALStarField::Star star{ LLVector3(0.f, 0.f, 1.f), LLColor4U(255, 255, 255, (U8)byte) }; + CAPTURE(byte, spike); + CHECK(ALStarField::spiked(star) == (spike > 0.f)); + } +} + +TEST_CASE("alstarfield: the layout puts the spiked stars first and keeps every star", "[alstarfield]") +{ + const std::vector field = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + std::vector stars = field; + const ALStarField::Layout layout = ALStarField::layOut(stars); + + REQUIRE(stars.size() == field.size()); + CHECK((size_t)layout.mQuads + layout.mPoints == field.size()); + // A few hundred of the bright tier. + CHECK(layout.mQuads > 100); + CHECK(layout.mQuads < 1000); + CHECK(layout.firstPoint() == layout.mQuads * ALStarField::QUAD_VERTICES); + CHECK(layout.vertices() == layout.firstPoint() + layout.mPoints); + + // Each part holds its kind, in the field's own order. + std::vector quads; + std::vector points; + for (const ALStarField::Star& star : field) + { + (ALStarField::spiked(star) ? quads : points).push_back(star); + } + REQUIRE(quads.size() == layout.mQuads); + size_t differ = 0; + for (size_t s = 0; s < stars.size(); ++s) + { + const ALStarField::Star& expected = s < quads.size() ? quads[s] : points[s - quads.size()]; + differ += !same(stars[s], expected); + } + CHECK(differ == 0); +} + +TEST_CASE("alstarfield: a star's vertex is its centre and its colour's bytes and a quad has six of them", "[alstarfield]") +{ + std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT / 50, DOME_RADIUS); + const ALStarField::Layout layout = ALStarField::layOut(stars); + REQUIRE(layout.mQuads > 0); + REQUIRE(layout.mPoints > 0); + + std::vector vertices(layout.vertices() + 1); + // Past the last vertex: nothing may be written there. + std::memset(&vertices.back(), 0xA5, sizeof(LLVector4a)); + LLStrider strider = vertices.data(); + ALStarField::fillVertices(stars, layout, strider); + + const auto holds = [](const LLVector4a& vertex, const ALStarField::Star& star) + { + const F32* xyz = vertex.getF32ptr(); + U8 colour[4]; + std::memcpy(colour, xyz + 3, sizeof(colour)); + return xyz[0] == star.mPosition.mV[VX] && xyz[1] == star.mPosition.mV[VY] && xyz[2] == star.mPosition.mV[VZ] + && std::memcmp(colour, star.mColour.mV, sizeof(colour)) == 0; + }; + size_t wrong = 0; + for (U32 v = 0; v < layout.vertices(); ++v) + { + const size_t star = v < layout.firstPoint() ? v / ALStarField::QUAD_VERTICES : v - layout.firstPoint() + layout.mQuads; + wrong += !holds(vertices[v], stars[star]); + } + CHECK(wrong == 0); + U8 untouched[sizeof(LLVector4a)]; + std::memset(untouched, 0xA5, sizeof(untouched)); + CHECK(std::memcmp(&vertices.back(), untouched, sizeof(untouched)) == 0); +} diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl index fcc3618c6b..4d45c2b664 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl @@ -30,30 +30,43 @@ out vec4 frag_data[4]; +#ifdef STAR_QUADS in vec2 vary_corner; // corner offset in [-1, 1] — radial mask source +#else +flat in vec3 vary_point; // the star's centre in window coordinates, and half its extent in pixels +#endif flat in vec3 vary_star_color; // the star's colour where its shape is 1 (starsV.glsl) flat in float vary_spike; // the diffraction spikes' strength void main() { +#ifdef STAR_QUADS + vec2 corner = vary_corner; +#else + // The pixel's offset from the star's centre, in [-1, 1] across the star with y up, as a quad's corner offset runs; + // the point is drawn wider than the star (starsV.glsl), and a pixel past the star's square is not its. + vec2 corner = (gl_FragCoord.xy - vary_point.xy) / vary_point.z; + if (max(abs(corner.x), abs(corner.y)) >= 1.0) discard; +#endif + // Procedural star shape. Using squared radius keeps falloff radial without // a sqrt; the small halo term adds a soft skirt that helps bright stars // integrate into the HDR/bloom pipeline. - float r2 = dot(vary_corner, vary_corner); + float r2 = dot(corner, corner); float core = exp(-r2 * 9.0); float halo = exp(-r2 * 1.8) * 0.06; // Diffraction spikes on the brightest tail only (starsV.glsl). - float ax = 1.0 - abs(vary_corner.x); - float ay = 1.0 - abs(vary_corner.y); - float spike_h = exp(-abs(vary_corner.y) * 32.0) * ax; - float spike_v = exp(-abs(vary_corner.x) * 32.0) * ay; + float ax = 1.0 - abs(corner.x); + float ay = 1.0 - abs(corner.y); + float spike_h = exp(-abs(corner.y) * 32.0) * ax; + float spike_v = exp(-abs(corner.x) * 32.0) * ay; float spike = (spike_h + spike_v) * vary_spike * 0.30; float shape = core + halo + spike; - // Discard pixels where the quad contributes effectively nothing - keeps - // the overdraw budget in check given how many tiny quads we render. + // Discard pixels where the star contributes effectively nothing - keeps + // the overdraw budget in check given how many stars we render. if (shape < 0.002) discard; // Shape is our final alpha mask. Pre-multiply RGB so we can blend additively diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl index 0eb6c78ee9..3f542284e6 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl @@ -29,13 +29,19 @@ // Shared matrix stack + derived matrices, spliced from // class1/deferred/matricesBlock.glsl and bound at UB_MATRICES. //[ENGINE_BLOCK Matrices] -uniform vec2 screen_res; -in vec3 position; // star center world position (shared by all 6 verts of a star) -in vec4 diffuse_color; // RGB = sRGB black-body color, A = intrinsic intensity (0..1) -in vec2 texcoord0; // corner offset in [-1, 1] for GPU-side billboarding +// One vertex a star, drawn as a point; or, for the stars with diffraction spikes (STAR_QUADS), six, drawn as a quad: GL +// drops a point whose centre leaves the screen, which would pop a spiked star's arms at the edge +// (LLDrawPoolWLSky::renderStarsDeferred, ALStarField). +in vec3 position; // the star's centre, in the stars' frame +in int texture_index; // its colour, in the position's fourth word: rgb = sRGB black-body colour, a = intrinsic intensity +uniform vec2 screen_res; // the viewport the stars draw into, in pixels +#ifdef STAR_QUADS out vec2 vary_corner; // corner offset, pass-through for fragment shape +#else +flat out vec3 vary_point; // the star's centre in window coordinates, and half its extent in pixels +#endif flat out vec3 vary_star_color; // what the star adds where its shape is 1: every per-star term but the shape flat out float vary_spike; // the diffraction spikes' strength @@ -122,18 +128,24 @@ void main() { // Outside the clip volume: nothing of the star is drawn. gl_Position = vec4(2.0, 2.0, 2.0, 1.0); +#ifdef STAR_QUADS vary_corner = vec2(0.0); +#else + gl_PointSize = 1.0; + vary_point = vec3(0.0, 0.0, 1.0); +#endif vary_star_color = vec3(0.0); vary_spike = 0.0; return; } - // Transform the star center to clip space. All 6 verts of a star share - // the same input position, so the center projects identically; the only + // Transform the star center to clip space. A quad's 6 verts share the + // same input position, so the center projects identically; the only // per-vertex change is the screen-space corner offset we add below. vec4 clip_center = modelview_projection_matrix * vec4(position, 1.0); - float intensity = diffuse_color.a; + vec4 star = unpackUnorm4x8(uint(texture_index)); + float intensity = star.a; // Resolution-adaptive pixel size. Base curve keeps the faint/mid majority // in the original 2..7px range (crisp point-sources), and only the very @@ -153,11 +165,24 @@ void main() float quad_pixels = max(ideal_px, min_px); float subpixel_fade = (ideal_px * ideal_px) / (quad_pixels * quad_pixels); - vec2 pixel_offset = texcoord0 * (quad_pixels * 0.5); +#ifdef STAR_QUADS + // The corner, from the vertex's place among its star's six: two triangles, BL BR TR and BL TR TL. + const vec2 CORNERS[6] = vec2[6](vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(1.0, 1.0), + vec2(-1.0, -1.0), vec2(1.0, 1.0), vec2(-1.0, 1.0)); + vec2 corner = CORNERS[gl_VertexID % 6]; + vec2 pixel_offset = corner * (quad_pixels * 0.5); // Convert pixels to NDC then to clip space (clip = ndc * w). vec2 ndc_offset = pixel_offset * (2.0 / max(screen_res, vec2(1.0))); clip_center.xy += ndc_offset * clip_center.w; +#else + // The star covers what its quad would: the pixels whose centres lie in a square quad_pixels a side about its + // centre. GL rounds a point's size to its granularity (0.125 pixels on NVIDIA) and measures gl_PointCoord across + // the rounded square, which would scale a 2.6-pixel star's shape by up to 5%; so the point is drawn a pixel wider, + // and starsF.glsl works each pixel's offset from the star's own centre and leaves out those past its square. + gl_PointSize = quad_pixels + 1.0; + vary_point = vec3((clip_center.xy / clip_center.w * 0.5 + 0.5) * screen_res, quad_pixels * 0.5); +#endif // On the far plane with the rest of the sky, so geometry occludes stars and the sun and // moon, drawn after them, cover them. Reverse-Z (glClipControl ZERO_TO_ONE) puts the far @@ -170,7 +195,9 @@ void main() #endif gl_Position = clip_center; - vary_corner = texcoord0; +#ifdef STAR_QUADS + vary_corner = corner; +#endif // NB: this linearises the sRGB black-body colour, and softenLight's SKIP_ATMOS branch // then srgb_to_linear's it a SECOND time and multiplies by sky_hdr_scale (the sky dome's @@ -178,7 +205,7 @@ void main() // Long-standing upstream behaviour. Net star brightness balances that double-decode // against the gains here (bloom_boost, the (1 + intensity) gain) and sky_hdr_scale -- if // star brightness is ever retuned, this whole chain is the place to look, not one line. - vec3 color = srgb_to_linear(diffuse_color.rgb); + vec3 color = srgb_to_linear(star.rgb); // --- Atmospheric extinction ---------------------------------------------- // Airmass grows sharply near the horizon. The wavelength-dependent @@ -253,7 +280,7 @@ void main() vary_star_color = star_color; // Diffraction spikes on the brightest tail only. Gated tight (0.90..1.0) - // so only real stand-out stars get crosses — the quad expands above for - // those stars, so the spike has room to render. + // so only real stand-out stars get crosses — their quads expand above, + // so the spike has room to render. vary_spike = smoothstep(0.90, 1.0, intensity); } diff --git a/indra/newview/lldrawpoolwlsky.cpp b/indra/newview/lldrawpoolwlsky.cpp index 82db119917..c4bea7c861 100644 --- a/indra/newview/lldrawpoolwlsky.cpp +++ b/indra/newview/lldrawpoolwlsky.cpp @@ -245,24 +245,14 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const return; } - gDeferredStarProgram.bind(); - gGL.pushMatrix(); gGL.translatef(camPosLocal.mV[0], camPosLocal.mV[1], camPosLocal.mV[2]); // Subtle rotation so fixed patterns drift over long time scales. const F32 star_rotation = (F32)fmod(gFrameTimeSeconds * 0.01, 360.0); gGL.rotatef(star_rotation, 0.f, 0.f, 1.f); - gDeferredStarProgram.uniform1f(LLShaderMgr::CUSTOM_ALPHA, star_alpha); - - // Screen resolution for GPU-side pixel-sized billboarding. - LLRenderTarget* deferred_target = &gPipeline.mRT->deferredScreen; - gDeferredStarProgram.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, - (GLfloat)deferred_target->getWidth(), - (GLfloat)deferred_target->getHeight()); - - // The stars' frame is the agent's turned by the rotation above, and the fragment shader works in it - // (vary_world_dir), so agent directions are turned back by the rotation's transpose. + // The stars' frame is the agent's turned by the rotation above, and the vertex shader works in it, so agent + // directions are turned back by the rotation's transpose. LLMatrix4a to_stars = LLMatrix4a::rotation(star_rotation * DEG_TO_RAD, LLVector4a(0.f, 0.f, 1.f)); to_stars.transpose(); const auto in_stars = [&to_stars](const LLVector3& v) @@ -274,9 +264,8 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const // Moon glare inputs, in the stars' frame so the glare stays on the moon however far the stars have turned. LLSettingsSky::ptr_t psky = LLEnvironment::instance().getCurrentSky(); - gDeferredStarProgram.uniform3fv(LLShaderMgr::DEFERRED_MOON_DIR, 1, in_stars(psky->getMoonDirection()).mV); - gDeferredStarProgram.uniform1f(LLShaderMgr::MOON_BRIGHTNESS, - psky->getIsMoonUp() ? (F32)psky->getMoonBrightness() : 0.0f); + const LLVector3 moon_dir = in_stars(psky->getMoonDirection()); + const F32 moon_brightness = psky->getIsMoonUp() ? (F32)psky->getMoonBrightness() : 0.0f; // Wrapped time for scintillation noise. 86400s is long enough that a // typical session never hits the wrap boundary (where the non-periodic @@ -284,14 +273,15 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const // precision stays good for the scaled time term in the shader. sStarTime = (F32)fmod(LLFrameTimer::getUptimeSeconds() * 0.5, 86400.0); - gDeferredStarProgram.uniform1f(LLShaderMgr::WATER_TIME, sStarTime); - // The moon draws after the stars and fades low in the sky, so the stars leave out what its quad and texture cover - // (starsF.glsl). Its corners lie around the camera in agent space. + // (starsV.glsl). Its corners lie around the camera in agent space. LLFace* moon_face = gSky.mVOSkyp->mFace[LLVOSky::FACE_MOON]; LLViewerTexture* moon_tex = moon_face ? moon_face->getTexture(LLRender::DIFFUSE_MAP) : nullptr; const LLHeavenBody& moon = gSky.mVOSkyp->getMoon(); const bool mask_moon = moon.getDraw() && moon_tex && moon_face->getGeomCount(); + LLVector3 moon_centre; + LLVector3 moon_u; + LLVector3 moon_v; if (mask_moon) { // Corners 0..3 are (-right, +up), (-right, -up), (+right, +up), (+right, -up) (LLVOSky::updateHeavenlyBodyGeometry). @@ -302,27 +292,58 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const half_u /= half_u.lengthSquared(); half_v /= half_v.lengthSquared(); - gDeferredStarProgram.uniform3fv(LLShaderMgr::MOON_QUAD_CENTER, 1, in_stars(centre / dist).mV); - gDeferredStarProgram.uniform3fv(LLShaderMgr::MOON_QUAD_U, 1, in_stars(half_u).mV); - gDeferredStarProgram.uniform3fv(LLShaderMgr::MOON_QUAD_V, 1, in_stars(half_v).mV); - gDeferredStarProgram.bindTexture(LLShaderMgr::DIFFUSE_MAP, moon_tex, ALSamplers::AnisoClamp); + moon_centre = in_stars(centre / dist); + moon_u = in_stars(half_u); + moon_v = in_stars(half_v); } - else + + // Both star programs take the same inputs. + const auto bind_stars = [&](LLGLSLShader& program) { - gDeferredStarProgram.uniform3f(LLShaderMgr::MOON_QUAD_CENTER, 0.f, 0.f, 0.f); - } + program.bind(); + program.uniform1f(LLShaderMgr::CUSTOM_ALPHA, star_alpha); + // The viewport the stars draw into, which the vertex stage sizes them in and the points find their centres + // by. + program.uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)LLRenderTarget::sCurResX, + (GLfloat)LLRenderTarget::sCurResY); + program.uniform3fv(LLShaderMgr::DEFERRED_MOON_DIR, 1, moon_dir.mV); + program.uniform1f(LLShaderMgr::MOON_BRIGHTNESS, moon_brightness); + program.uniform1f(LLShaderMgr::WATER_TIME, sStarTime); + if (mask_moon) + { + program.uniform3fv(LLShaderMgr::MOON_QUAD_CENTER, 1, moon_centre.mV); + program.uniform3fv(LLShaderMgr::MOON_QUAD_U, 1, moon_u.mV); + program.uniform3fv(LLShaderMgr::MOON_QUAD_V, 1, moon_v.mV); + program.bindTexture(LLShaderMgr::DIFFUSE_MAP, moon_tex, ALSamplers::AnisoClamp); + } + else + { + program.uniform3f(LLShaderMgr::MOON_QUAD_CENTER, 0.f, 0.f, 0.f); + } + }; + const auto unbind_stars = [&](LLGLSLShader& program) + { + if (mask_moon) + { + program.disableTexture(LLShaderMgr::DIFFUSE_MAP); + } + program.unbind(); + }; { LL_PROFILE_GPU_ZONE("sky stars"); - gSky.mVOWLSkyp->drawStars(); - } - if (mask_moon) - { - gDeferredStarProgram.disableTexture(LLShaderMgr::DIFFUSE_MAP); - } + // The spiked stars, few and bright, as quads (ALStarField::SPIKE_INTENSITY). + bind_stars(gDeferredStarQuadProgram); + gSky.mVOWLSkyp->drawStarQuads(); + unbind_stars(gDeferredStarQuadProgram); - gDeferredStarProgram.unbind(); + // Every other star as a point, sized by its vertex shader. + LLGLEnable point_size(GL_PROGRAM_POINT_SIZE); + bind_stars(gDeferredStarProgram); + gSky.mVOWLSkyp->drawStarPoints(); + unbind_stars(gDeferredStarProgram); + } gGL.popMatrix(); } diff --git a/indra/newview/lldrawpoolwlsky.h b/indra/newview/lldrawpoolwlsky.h index 066df2d469..ddb642ea1a 100644 --- a/indra/newview/lldrawpoolwlsky.h +++ b/indra/newview/lldrawpoolwlsky.h @@ -39,8 +39,9 @@ class LLDrawPoolWLSky : public LLDrawPool { static const U32 SKY_VERTEX_DATA_MASK = LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_TEXCOORD0; + // A star's centre, and its colour in the position's fourth word, where the texture index goes (ALStarField). static const U32 STAR_VERTEX_DATA_MASK = LLVertexBuffer::MAP_VERTEX | - LLVertexBuffer::MAP_COLOR | LLVertexBuffer::MAP_TEXCOORD0; + LLVertexBuffer::MAP_TEXTURE_INDEX; static const U32 METEOR_VERTEX_DATA_MASK = LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_NORMAL | LLVertexBuffer::MAP_COLOR | LLVertexBuffer::MAP_TEXCOORD0; diff --git a/indra/newview/llviewershadermgr.cpp b/indra/newview/llviewershadermgr.cpp index cb2f7deaff..2f9158ade3 100644 --- a/indra/newview/llviewershadermgr.cpp +++ b/indra/newview/llviewershadermgr.cpp @@ -227,6 +227,7 @@ LLGLSLShader gDeferredWLCloudProgram; LLGLSLShader gDeferredWLSunProgram; LLGLSLShader gDeferredWLMoonProgram; LLGLSLShader gDeferredStarProgram; +LLGLSLShader gDeferredStarQuadProgram; LLGLSLShader gDeferredMeteorProgram; LLGLSLShader gDeferredAuroraProgram; LLGLSLShader gDeferredFullbrightShinyProgram; @@ -1392,6 +1393,7 @@ void LLViewerShaderMgr::loadShadersDeferred() gDeferredWLSunProgram.unload(); gDeferredWLMoonProgram.unload(); gDeferredStarProgram.unload(); + gDeferredStarQuadProgram.unload(); gDeferredMeteorProgram.unload(); gDeferredAuroraProgram.unload(); gDeferredFullbrightShinyProgram.unload(); @@ -3248,6 +3250,21 @@ void LLViewerShaderMgr::loadShadersDeferred() built(gDeferredStarProgram.createShader(), gDeferredStarProgram); } + { + // The stars with diffraction spikes, as quads (ALStarField::SPIKE_INTENSITY). + gDeferredStarQuadProgram.mName = "Deferred Star Quad Program"; + gDeferredStarQuadProgram.mShaderFiles.clear(); + gDeferredStarQuadProgram.mShaderFiles.push_back(make_pair("deferred/starsV.glsl", GL_VERTEX_SHADER)); + gDeferredStarQuadProgram.mShaderFiles.push_back(make_pair("deferred/starsF.glsl", GL_FRAGMENT_SHADER)); + gDeferredStarQuadProgram.mShaderLevel = mShaderLevel[SHADER_DEFERRED]; + gDeferredStarQuadProgram.mShaderGroup = LLGLSLShader::SG_SKY; + gDeferredStarQuadProgram.addPermutation("STAR_QUADS", "1"); + + add_common_permutations(&gDeferredStarQuadProgram); + + built(gDeferredStarQuadProgram.createShader(), gDeferredStarQuadProgram); + } + { gDeferredMeteorProgram.mName = "Deferred Meteor Program"; gDeferredMeteorProgram.mShaderFiles.clear(); diff --git a/indra/newview/llviewershadermgr.h b/indra/newview/llviewershadermgr.h index ebd40d141a..92b438fc31 100644 --- a/indra/newview/llviewershadermgr.h +++ b/indra/newview/llviewershadermgr.h @@ -290,6 +290,7 @@ extern LLGLSLShader gDeferredWLCloudProgram; extern LLGLSLShader gDeferredWLSunProgram; extern LLGLSLShader gDeferredWLMoonProgram; extern LLGLSLShader gDeferredStarProgram; +extern LLGLSLShader gDeferredStarQuadProgram; extern LLGLSLShader gDeferredMeteorProgram; extern LLGLSLShader gDeferredAuroraProgram; extern LLGLSLShader gDeferredFullbrightShinyProgram; diff --git a/indra/newview/llvowlsky.cpp b/indra/newview/llvowlsky.cpp index f3825ae114..c73a08734a 100644 --- a/indra/newview/llvowlsky.cpp +++ b/indra/newview/llvowlsky.cpp @@ -70,14 +70,6 @@ namespace } } -U32 LLVOWLSky::getStarsNumVerts(void) const -{ - // mStars is the source of truth — its size reflects whatever - // RenderStarCount asked for at the last initStars() call (after - // proportional scaling and rounding). - return (U32)mStars.size(); -} - LLVOWLSky::LLVOWLSky(const LLUUID &id, const LLPCode pcode, LLViewerRegion *regionp) : LLStaticViewerObject(id, pcode, regionp, true) { @@ -243,13 +235,21 @@ bool LLVOWLSky::updateGeometry(LLDrawable * drawable) return true; } -void LLVOWLSky::drawStars(void) +void LLVOWLSky::drawStarPoints() { - // render the stars as a sphere centered at viewer camera - if (mStarsVerts.notNull()) + if (mStarsVerts.notNull() && mStarLayout.mPoints > 0) + { + mStarsVerts->setBuffer(); + mStarsVerts->drawArrays(LLRender::POINTS, mStarLayout.firstPoint(), mStarLayout.mPoints); + } +} + +void LLVOWLSky::drawStarQuads() +{ + if (mStarsVerts.notNull() && mStarLayout.mQuads > 0) { mStarsVerts->setBuffer(); - mStarsVerts->drawArrays(LLRender::TRIANGLES, 0, (S32)(getStarsNumVerts() * 6)); + mStarsVerts->drawArrays(LLRender::TRIANGLES, 0, mStarLayout.firstPoint()); } } @@ -287,6 +287,7 @@ void LLVOWLSky::initStars() const S32 requested = gSavedSettings.getS32("RenderStarCount"); mStars = ALStarField::generate((U32)llmax(requested, 0), LLEnvironment::instance().getCurrentSky()->getDomeRadius()); + mStarLayout = ALStarField::layOut(mStars); } bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) @@ -301,15 +302,10 @@ bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) return true; } - LLStrider verticesp; - LLStrider colorsp; - LLStrider texcoordsp; - - const U32 num_stars = getStarsNumVerts(); - const U32 num_verts = num_stars * 6; + const U32 num_verts = mStarLayout.vertices(); // RenderStarCount = 0 disables stars entirely. Leave mStarsVerts null so - // drawStars short-circuits. + // the star draws short-circuit. if (num_verts == 0) { return true; @@ -319,43 +315,19 @@ bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) if (!mStarsVerts->allocateBuffer(num_verts, 0)) { LL_WARNS() << "Failed to allocate Vertex Buffer for Sky to " << num_verts << " vertices" << LL_ENDL; + mStarsVerts = nullptr; return false; } - bool success = mStarsVerts->getVertexStrider(verticesp) - && mStarsVerts->getColorStrider(colorsp) - && mStarsVerts->getTexCoord0Strider(texcoordsp); - - if(!success) + LLStrider verticesp; + if (!mStarsVerts->getVertexStrider(verticesp)) { LL_ERRS() << "Failed updating star geometry." << LL_ENDL; } - // Two-triangle quad. Corner offsets are in [-1, 1] — the vertex shader - // turns them into pixel-sized screen-space offsets, so the actual CPU - // "position" of every vertex of a star is identical (the star center). - // This trades a small amount of memory for full resolution/FOV control on - // the GPU side. - static const LLVector2 kCornerOffsets[6] = { - LLVector2(-1.f, -1.f), // 0: BL - LLVector2( 1.f, -1.f), // 1: BR - LLVector2( 1.f, 1.f), // 2: TR - LLVector2(-1.f, -1.f), // 3: BL - LLVector2( 1.f, 1.f), // 4: TR - LLVector2(-1.f, 1.f), // 5: TL - }; - - for (U32 s = 0; s < num_stars; ++s) - { - const ALStarField::Star& star = mStars[s]; - - for (U32 v = 0; v < 6; ++v) - { - *(verticesp++) = star.mPosition; - *(colorsp++) = star.mColour; - *(texcoordsp++) = kCornerOffsets[v]; - } - } + // One vertex a star, drawn as a point the vertex shader sizes; six for each spiked star, which it spreads into a + // quad. + ALStarField::fillVertices(mStars, mStarLayout, verticesp); mStarsVerts->unmapBuffer(); return true; diff --git a/indra/newview/llvowlsky.h b/indra/newview/llvowlsky.h index 9fde5a1e30..e3d374904a 100644 --- a/indra/newview/llvowlsky.h +++ b/indra/newview/llvowlsky.h @@ -37,9 +37,6 @@ #include class LLVOWLSky : public LLStaticViewerObject { -private: - U32 getStarsNumVerts(void) const; - public: LLVOWLSky(const LLUUID &id, const LLPCode pcode, LLViewerRegion *regionp); @@ -48,7 +45,9 @@ class LLVOWLSky : public LLStaticViewerObject { /*virtual*/ LLDrawable * createDrawable(LLPipeline *pipeline); /*virtual*/ bool updateGeometry(LLDrawable *drawable); - void drawStars(void); + // The stars drawn as points, and the spiked stars as quads (ALStarField::Layout), each with its own program bound. + void drawStarPoints(); + void drawStarQuads(); void drawDome(void); void resetVertexBuffers(void); @@ -75,8 +74,9 @@ class LLVOWLSky : public LLStaticViewerObject { LLPointer mStarsVerts; LLPointer mMeteorVerts; - // The star field RenderStarCount asked for, in the order the buffer takes it. + // The star field RenderStarCount asked for, in the order the buffer takes it, and where each kind lies in it. std::vector mStars; + ALStarField::Layout mStarLayout; struct MeteorState { From 666714d3e839574ec979f2f139ff5c861810f975 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 04:53:21 -0400 Subject: [PATCH 4/7] Bin the stars into sky cells and draw only the cells in view Every star was drawn every frame wherever the camera looked, though a normal field of view takes in about a sixth of the upper hemisphere. ALStarField now bins the points (not the 295 spiked quads, which stay one small draw) into 512 cells, 16 bands of equal height in z, so of equal area, each cut into 32 arcs of azimuth, and lays the buffer out cell by cell. Each cell keeps its run of vertices and a cone about the camera holding every one of its stars' directions: about their mean, as wide as the farthest. Each frame the star draw takes the side planes of its own modelview-projection, which already turns the agent's frame into the stars' (the translation to the camera and the slow turn about z), so the cull runs in the stars' frame with no rotation of its own. A star is thousands of times farther than the camera is from the stars' origin, so a direction is in view where each plane's normal makes a non-negative product with it, and a cell is out where one plane has its whole cone outside. The planes are moved out by POINT_REACH (4 pixels: half the widest point and the pixel starsV widens it by), so no point a driver would draw from a centre just off the edge is lost. The cells in view become glMultiDrawArrays runs, those that meet merged, through LLVertexBuffer::drawMultiArrays, which syncs the matrices and counts the draw as drawArrays does. The cull is a CPU zone, "sky stars cull". alstarfield_test checks that the cells lay the points out in order, each star in the cell cellOf names and inside its cell's cone, no cell of the default field empty; that over 47 views (level, up, at the zenith, below the horizon, rolled, from 10 to 120 degrees, and 40 at random) every point that lands within the widened clip square is in a cell the cull keeps, and the draws are exactly the kept cells; and that looking 35 degrees up through 60 degrees at 16:9 the view draws 25,902 of the 154,705 points, 6.0 times fewer, in 17 runs, where 19,182 land in view. alstarfield_gl_test draws every point and then only the culled runs: the pictures are identical in all six scenes on both paths, while the views draw 22k to 84k of the 155k points. The A/B against the old quads is unchanged. With the cone's radius left out of the test, views lose points in view and the culled pictures lose stars. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/alstarfield.cpp | 135 ++++++++++++ indra/alrender/alstarfield.h | 69 +++++- indra/alrender/tests/alstarfield_draws.h | 41 ++++ indra/alrender/tests/alstarfield_gl_test.cpp | 56 ++++- indra/alrender/tests/alstarfield_test.cpp | 199 +++++++++++++++++- indra/llrender/llvertexbuffer.cpp | 22 ++ indra/llrender/llvertexbuffer.h | 2 + .../shaders/class1/deferred/starsV.glsl | 2 +- indra/newview/lldrawpoolwlsky.cpp | 10 +- indra/newview/llvowlsky.cpp | 16 +- indra/newview/llvowlsky.h | 6 +- 11 files changed, 536 insertions(+), 22 deletions(-) create mode 100644 indra/alrender/tests/alstarfield_draws.h diff --git a/indra/alrender/alstarfield.cpp b/indra/alrender/alstarfield.cpp index 1b8751f708..0af1e98f32 100644 --- a/indra/alrender/alstarfield.cpp +++ b/indra/alrender/alstarfield.cpp @@ -28,6 +28,7 @@ #include "alstarfieldgenerate.h" #include "llmath.h" +#include "llmatrix4a.h" #include "v3color.h" #include "v4color.h" @@ -108,6 +109,20 @@ std::vector generate(U32 count, F32 dome_radius) return detail::generate(count, dome_radius, [](const LLVector3& direction) { return milkyWayDensity(direction); }); } +U32 cellOf(const LLVector3& direction) +{ + const F32 length = direction.length(); + const F32 z = length > 0.f ? llclamp(direction.mV[VZ] / length, 0.f, 1.f) : 0.f; + const U32 band = llmin((U32)(z * (F32)BANDS), BANDS - 1); + F32 azimuth = atan2f(direction.mV[VY], direction.mV[VX]); + if (azimuth < 0.f) + { + azimuth += F_TWO_PI; + } + const U32 segment = llmin((U32)(azimuth / F_TWO_PI * (F32)SEGMENTS), SEGMENTS - 1); + return band * SEGMENTS + segment; +} + Layout layOut(std::vector& stars) { const auto points = std::stable_partition(stars.begin(), stars.end(), spiked); @@ -115,6 +130,59 @@ Layout layOut(std::vector& stars) Layout layout; layout.mQuads = (U32)(points - stars.begin()); layout.mPoints = (U32)(stars.end() - points); + + // The points by cell. + std::vector cell(layout.mPoints); + std::vector order(layout.mPoints); + for (U32 p = 0; p < layout.mPoints; ++p) + { + cell[p] = cellOf(points[p].mPosition); + order[p] = p; + } + std::stable_sort(order.begin(), order.end(), [&cell](U32 a, U32 b) { return cell[a] < cell[b]; }); + std::vector sorted; + sorted.reserve(layout.mPoints); + for (U32 p : order) + { + sorted.push_back(points[p]); + } + std::copy(sorted.begin(), sorted.end(), points); + + // Each cell's run, and the cone its stars' directions lie in: about their mean, as wide as the farthest. + layout.mCells.resize(CELLS); + U32 first = 0; + for (U32 c = 0; c < CELLS; ++c) + { + Cell& run = layout.mCells[c]; + run.mFirst = layout.firstPoint() + first; + while (first + run.mCount < layout.mPoints && cell[order[first + run.mCount]] == c) + { + ++run.mCount; + } + + LLVector3 sum; + for (U32 p = first; p < first + run.mCount; ++p) + { + sum += points[p].mPosition / points[p].mPosition.length(); + } + if (run.mCount == 0 || sum.normalize() == 0.f) + { + run.mAxis.set(0.f, 0.f, 1.f); + run.mSinRadius = run.mCount == 0 ? 0.f : 1.f; + } + else + { + run.mAxis = sum; + F32 least_cos = 1.f; + for (U32 p = first; p < first + run.mCount; ++p) + { + least_cos = llmin(least_cos, run.mAxis * (points[p].mPosition / points[p].mPosition.length())); + } + run.mSinRadius = least_cos > 0.f ? sqrtf(llmax(0.f, 1.f - least_cos * least_cos)) : 1.f; + } + first += run.mCount; + } + llassert(first == layout.mPoints); return layout; } @@ -143,4 +211,71 @@ void fillVertices(const std::vector& stars, const Layout& layout, LLStride } } +Frustum frustum(const LLMatrix4a& mvp, F32 margin_x, F32 margin_y) +{ + // The clip coordinates' x, y and w as products with a point of the stars' frame, through the lanes the matrix's + // rows weight them by. + const LLVector4a x = mvp.getColumn<0>(); + const LLVector4a y = mvp.getColumn<1>(); + const LLVector4a w = mvp.getColumn<3>(); + const auto plane = [&w](F32 margin, const LLVector4a& axis, F32 sign) + { + LLVector3 normal; + for (S32 i = 0; i < 3; ++i) + { + normal.mV[i] = (1.f + margin) * w[i] + sign * axis[i]; + } + normal.normalize(); + return normal; + }; + + Frustum frustum; + frustum.mNormals[0] = plane(margin_x, x, 1.f); + frustum.mNormals[1] = plane(margin_x, x, -1.f); + frustum.mNormals[2] = plane(margin_y, y, 1.f); + frustum.mNormals[3] = plane(margin_y, y, -1.f); + return frustum; +} + +Frustum pointFrustum(const LLMatrix4a& mvp, U32 width, U32 height) +{ + return frustum(mvp, 2.f * POINT_REACH / (F32)llmax(width, 1u), 2.f * POINT_REACH / (F32)llmax(height, 1u)); +} + +bool inView(const Frustum& frustum, const Cell& cell) +{ + // The cone lies wholly outside a plane through its apex where its axis makes more than 90 degrees and its + // half-angle with the plane's normal; the slack keeps a star rounding onto the plane in view. + constexpr F32 SLACK = 1e-4f; + for (const LLVector3& normal : frustum.mNormals) + { + if (normal * cell.mAxis < -(cell.mSinRadius + SLACK)) + { + return false; + } + } + return true; +} + +void pointDraws(const Layout& layout, const Frustum& frustum, Draws& draws) +{ + draws.clear(); + for (const Cell& cell : layout.mCells) + { + if (cell.mCount == 0 || !inView(frustum, cell)) + { + continue; + } + if (!draws.mFirst.empty() && (U32)(draws.mFirst.back() + draws.mCount.back()) == cell.mFirst) + { + draws.mCount.back() += (S32)cell.mCount; + } + else + { + draws.mFirst.push_back((S32)cell.mFirst); + draws.mCount.push_back((S32)cell.mCount); + } + } +} + } // namespace ALStarField diff --git a/indra/alrender/alstarfield.h b/indra/alrender/alstarfield.h index 547c46e06f..5630d25a25 100644 --- a/indra/alrender/alstarfield.h +++ b/indra/alrender/alstarfield.h @@ -35,6 +35,8 @@ #include #include +class LLMatrix4a; + /// The star field, worked from a star count and the dome's radius alone: no GL and no settings, so LLVOWLSky's buffer /// and the GL tests that draw it share one definition. /// @@ -88,8 +90,8 @@ std::vector generate(U32 count, F32 dome_radius); /// The faintest intensity, as the colour's alpha holds it, that has diffraction spikes: starsV.glsl's /// smoothstep(0.90, 1.0, intensity) is above 0 past 0.90, and 230 / 255 is the first step past it. Such a star reaches -/// up to 19 pixels across, and GL drops a point whose centre leaves the screen, so its arms would pop at the edge: it is -/// drawn as a quad instead. Every other star is a point, at most 7 pixels across. +/// up to 19 pixels across, and GL drops a point whose centre leaves the screen, so its arms would pop at the edge: it +/// is drawn as a quad instead. Every other star is a point, at most 7 pixels across. constexpr U8 SPIKE_INTENSITY = 230; inline bool spiked(const Star& star) @@ -101,20 +103,77 @@ inline bool spiked(const Star& star) /// lying first in the buffer, from vertex 0. constexpr U32 QUAD_VERTICES = 6; -/// Where the stars lie in the buffer: the spiked stars' quads first, QUAD_VERTICES vertices each, then every other star, -/// one vertex each, drawn as points. +/// The sky's cells, which the points are binned into so a view draws only those it can see: BANDS bands of equal height +/// in z, so of equal area, over the upper hemisphere, each cut into SEGMENTS equal arcs of azimuth. Each is about 6 +/// degrees across, but those of the top band, which are wedges 20 degrees long. +constexpr U32 BANDS = 16; +constexpr U32 SEGMENTS = 32; +constexpr U32 CELLS = BANDS * SEGMENTS; + +/// The cell a direction from the camera lies in; one below the horizon counts in the lowest band. +U32 cellOf(const LLVector3& direction); + +/// A cell's points in the buffer, and a cone about the camera holding every one of their directions. +struct Cell +{ + U32 mFirst = 0; ///< its first vertex + U32 mCount = 0; ///< its stars + LLVector3 mAxis; ///< the cone's axis, unit + F32 mSinRadius = 0.f; ///< the sine of its half-angle, which is under 90 degrees +}; + +/// Where the stars lie in the buffer: the spiked stars' quads first, QUAD_VERTICES vertices each, then every other +/// star, one vertex each, drawn as points, cell by cell. struct Layout { U32 mQuads = 0; ///< spiked stars U32 mPoints = 0; ///< the rest + std::vector mCells; ///< CELLS of them, in order, each starting where the one before ends U32 firstPoint() const { return mQuads * QUAD_VERTICES; } U32 vertices() const { return firstPoint() + mPoints; } }; -/// Orders @a stars as the buffer lays them out, and says where each kind lies. +/// Orders @a stars as the buffer lays them out, and says where each kind and each cell lies. Layout layOut(std::vector& stars); +/// The most a point reaches past its centre, in pixels: half the widest star drawn as a point (6.5 pixels across, just +/// under SPIKE_INTENSITY) and the half pixel starsV.glsl widens each side by. GL drops a point whose centre leaves the +/// clip volume, but a view's cull keeps every point this near the edge, so nothing a driver would draw is left out. +constexpr F32 POINT_REACH = 4.f; + +/// The side planes of a view, in the stars' frame and through the camera at its origin: the inward normals, unit, of +/// x = -w, x = w, y = -w and y = w through @a mvp (the stars' frame to clip space), each plane moved out by @a margin_x +/// or @a margin_y of normalized device coordinates. A star is thousands of times farther from the camera than the +/// camera is from the stars' origin, so a direction is in view where each normal makes a non-negative product with it. +struct Frustum +{ + LLVector3 mNormals[4]; +}; +Frustum frustum(const LLMatrix4a& mvp, F32 margin_x, F32 margin_y); + +/// The frustum of a view @a width x @a height pixels through @a mvp, each side moved out by POINT_REACH pixels. +Frustum pointFrustum(const LLMatrix4a& mvp, U32 width, U32 height); + +/// Whether any direction of @a cell's cone may lie inside @a frustum: false only where one plane has every one outside. +bool inView(const Frustum& frustum, const Cell& cell); + +/// The runs of points a view draws, for glMultiDrawArrays: each cell in view, and runs that meet merged. +struct Draws +{ + std::vector mFirst; + std::vector mCount; + + void clear() + { + mFirst.clear(); + mCount.clear(); + } +}; + +/// The points of @a layout that a view through @a frustum draws, into @a draws. +void pointDraws(const Layout& layout, const Frustum& frustum, Draws& draws); + /// The buffer's vertices for @a stars laid out as @a layout, layout.vertices() of them: a star's centre in xyz and its /// colour's four bytes in w, the word where LLVertexBuffer keeps the texture index, so a star is 16 bytes. A quad's /// star is written QUAD_VERTICES times. diff --git a/indra/alrender/tests/alstarfield_draws.h b/indra/alrender/tests/alstarfield_draws.h new file mode 100644 index 0000000000..4f328fc03d --- /dev/null +++ b/indra/alrender/tests/alstarfield_draws.h @@ -0,0 +1,41 @@ +/** + * @file alstarfield_draws.h + * @brief What the star field's tests read of a view's draws besides the runs themselves. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#pragma once + +#include "../alstarfield.h" + +namespace al_star_test +{ + // The points @a draws draws in all: its runs' counts summed. + inline U32 drawnPoints(const ALStarField::Draws& draws) + { + U32 points = 0; + for (const S32 count : draws.mCount) + { + points += (U32)count; + } + return points; + } +} diff --git a/indra/alrender/tests/alstarfield_gl_test.cpp b/indra/alrender/tests/alstarfield_gl_test.cpp index daa426692f..272e676a3f 100644 --- a/indra/alrender/tests/alstarfield_gl_test.cpp +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -42,6 +42,7 @@ #include "llvertexbuffer.h" #include "../../llrender/tests/llheadlessgl_fixture.h" +#include "alstarfield_draws.h" #include #include @@ -304,7 +305,10 @@ void main() { std::vector> mAttachments; - const F32* pixel(S32 attachment, S32 x, S32 y) const { return &mAttachments[attachment][(size_t(y) * W + x) * 4]; } + const F32* pixel(S32 attachment, S32 x, S32 y) const + { + return &mAttachments[attachment][(size_t(y) * W + x) * 4]; + } }; // A step of the attachment's storage at @a value: a step of 8 bits, or of R11G11B10F's mantissa, six bits for red @@ -475,7 +479,8 @@ void main() static const LLVector2 CORNERS[6] = { LLVector2(-1.f, -1.f), LLVector2(1.f, -1.f), LLVector2(1.f, 1.f), LLVector2(-1.f, -1.f), LLVector2(1.f, 1.f), LLVector2(-1.f, 1.f) }; - mOldQuads = new LLVertexBuffer(LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_COLOR | LLVertexBuffer::MAP_TEXCOORD0); + mOldQuads = new LLVertexBuffer(LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_COLOR + | LLVertexBuffer::MAP_TEXCOORD0); REQUIRE(mOldQuads->allocateBuffer((U32)mField.size() * 6, 0)); LLStrider vertices; LLStrider colours; @@ -636,8 +641,9 @@ void main() return picture; } - // The stars drawn as LLDrawPoolWLSky::renderStarsDeferred draws them, into the G-buffer of @a emissive's path. - Picture drawStars(const Scene& scene, bool emissive) + // The stars drawn as LLDrawPoolWLSky::renderStarsDeferred draws them, into the G-buffer of @a emissive's path; + // every point, the cull's aside, where @a everything. + Picture drawStars(const Scene& scene, bool emissive, bool everything = false) { LLRenderTarget& target = mTarget[emissive]; target.bindTarget(); @@ -660,12 +666,24 @@ void main() mStarBuffer->drawArrays(LLRender::TRIANGLES, 0, mLayout.firstPoint()); quads.unbind(); + LLMatrix4a mvp; + mvp.setMul(gGL.getModelviewMatrix(), gGL.getProjectionMatrix()); + ALStarField::pointDraws(mLayout, ALStarField::pointFrustum(mvp, W, H), mDraws); + if (everything) + { + mDraws.clear(); + mDraws.mFirst.push_back((S32)mLayout.firstPoint()); + mDraws.mCount.push_back((S32)mLayout.mPoints); + mDraws.mPoints = mLayout.mPoints; + } + LLGLEnable point_size(GL_PROGRAM_POINT_SIZE); LLGLSLShader& points = mStarPoints[emissive]; points.bind(); bindUniforms(points, scene); mStarBuffer->setBuffer(); - mStarBuffer->drawArrays(LLRender::POINTS, mLayout.firstPoint(), mLayout.mPoints); + mStarBuffer->drawMultiArrays(LLRender::POINTS, mDraws.mFirst.data(), mDraws.mCount.data(), + (U32)mDraws.mFirst.size()); points.unbind(); gGL.popMatrix(); gGL.setSceneBlendType(LLRender::BT_ALPHA); @@ -710,7 +728,8 @@ void main() const F32 diff = fabsf(pa[c] - pb[c]); most = llmax(most, diff); lit = lit || pa[c] != pn[c] || pb[c] != pn[c]; - beyond = beyond || diff > storageStep(llmax(pa[c], pb[c]), emissive, c) + HALF_QUANTUM + allowance; + const F32 bound = storageStep(llmax(pa[c], pb[c]), emissive, c) + HALF_QUANTUM + allowance; + beyond = beyond || diff > bound; } d.mMost = llmax(d.mMost, most); d.mDiffer += most > 0.f; @@ -728,6 +747,7 @@ void main() std::vector mField; std::vector mLaidOut; ALStarField::Layout mLayout; + ALStarField::Draws mDraws; LLPointer mOldQuads; LLPointer mStarBuffer; LLPointer mMoonTexture; @@ -778,3 +798,27 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the pictu } } } + +TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the cull leaves out only cells that draw nothing", "[alstarfield_gl]") +{ + setUp(); + for (const Scene& scene : SCENES) + { + for (S32 path = 0; path < 2; ++path) + { + const bool emissive = path == 1; + const S32 attachment = emissive ? 3 : 0; + CAPTURE(scene.mName, emissive); + const Picture blank = drawNothing(emissive); + const Picture all = drawStars(scene, emissive, true); + const Picture culled = drawStars(scene, emissive); + const U32 drawn = al_star_test::drawnPoints(mDraws); + const Difference d = compare(all, culled, blank, attachment); + const std::string summary = d.summary(); + CAPTURE(summary, drawn, mLayout.mPoints); + CHECK(d.mDiffer == 0); + UNSCOPED_INFO("the cull leaves cells out"); + CHECK(drawn < mLayout.mPoints * 3 / 4); + } + } +} diff --git a/indra/alrender/tests/alstarfield_test.cpp b/indra/alrender/tests/alstarfield_test.cpp index ce567e8613..1ba4442304 100644 --- a/indra/alrender/tests/alstarfield_test.cpp +++ b/indra/alrender/tests/alstarfield_test.cpp @@ -1,7 +1,7 @@ /** * @file alstarfield_test.cpp * @brief The star field: the same field from the same count in every session, laid out in its tiers, and the - * buffer it is drawn from. + * buffer it is drawn from, binned into cells a view culls. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -31,15 +31,19 @@ #include "../alstarfieldgenerate.h" #include "../alstarfield.h" +#include "alstarfield_draws.h" +#include "llmatrix4a.h" #include "llrand.h" #include "llstrider.h" #include "llvector4a.h" +#include #include #include #include #include +#include #include #include #include @@ -303,13 +307,16 @@ TEST_CASE("alstarfield: the layout puts the spiked stars first and keeps every s CHECK(layout.firstPoint() == layout.mQuads * ALStarField::QUAD_VERTICES); CHECK(layout.vertices() == layout.firstPoint() + layout.mPoints); - // Each part holds its kind, in the field's own order. + // Each part holds its kind: the quads in the field's own order, the points cell by cell, in the field's order + // within a cell. std::vector quads; std::vector points; for (const ALStarField::Star& star : field) { (ALStarField::spiked(star) ? quads : points).push_back(star); } + std::stable_sort(points.begin(), points.end(), [](const ALStarField::Star& a, const ALStarField::Star& b) + { return ALStarField::cellOf(a.mPosition) < ALStarField::cellOf(b.mPosition); }); REQUIRE(quads.size() == layout.mQuads); size_t differ = 0; for (size_t s = 0; s < stars.size(); ++s) @@ -344,7 +351,8 @@ TEST_CASE("alstarfield: a star's vertex is its centre and its colour's bytes and size_t wrong = 0; for (U32 v = 0; v < layout.vertices(); ++v) { - const size_t star = v < layout.firstPoint() ? v / ALStarField::QUAD_VERTICES : v - layout.firstPoint() + layout.mQuads; + const size_t star = v < layout.firstPoint() ? v / ALStarField::QUAD_VERTICES + : v - layout.firstPoint() + layout.mQuads; wrong += !holds(vertices[v], stars[star]); } CHECK(wrong == 0); @@ -352,3 +360,188 @@ TEST_CASE("alstarfield: a star's vertex is its centre and its colour's bytes and std::memset(untouched, 0xA5, sizeof(untouched)); CHECK(std::memcmp(&vertices.back(), untouched, sizeof(untouched)) == 0); } + +namespace +{ + // A camera: its bearing clockwise from north and pitch above the horizon, its roll, its vertical field of view and + // aspect, all in degrees but the aspect; and how far the stars have turned about z. + struct View + { + F32 mBearing; + F32 mPitch; + F32 mRoll; + F32 mFov; + F32 mAspect; + F32 mRotation; + }; + + // The stars' frame to clip space for @a view, built as LLRender builds it for the star draw: the view from a camera + // in the agent's frame, then the stars taken to the camera and turned (LLDrawPoolWLSky::renderStarsDeferred). + LLMatrix4a starMVP(const View& view) + { + const LLVector3 camera(118.f, -43.f, 27.f); + LLMatrix4a mv; + mv.setIdentity(); + const auto apply = [&mv](const LLMatrix4a& m) { mv.setMul(m, mv); }; + apply(LLMatrix4a::rotation(view.mRoll * DEG_TO_RAD, LLVector4a(0.f, 0.f, 1.f))); + apply(LLMatrix4a::rotation(-view.mPitch * DEG_TO_RAD, LLVector4a(1.f, 0.f, 0.f))); + apply(LLMatrix4a::rotation(-90.f * DEG_TO_RAD, LLVector4a(1.f, 0.f, 0.f))); + apply(LLMatrix4a::rotation(view.mBearing * DEG_TO_RAD, LLVector4a(0.f, 0.f, 1.f))); + apply(LLMatrix4a::translation(-camera.mV[VX], -camera.mV[VY], -camera.mV[VZ])); + apply(LLMatrix4a::translation(camera.mV[VX], camera.mV[VY], camera.mV[VZ])); + apply(LLMatrix4a::rotation(view.mRotation * DEG_TO_RAD, LLVector4a(0.f, 0.f, 1.f))); + + constexpr F32 NEAR_CLIP = 0.5f; + constexpr F32 FAR_CLIP = 1024.f; + const F32 tan_half = tanf(view.mFov * 0.5f * DEG_TO_RAD); + LLMatrix4a proj; + proj.clear(); + F32* p = proj.getF32ptr(); + p[0] = 1.f / (tan_half * view.mAspect); + p[5] = 1.f / tan_half; + p[10] = (FAR_CLIP + NEAR_CLIP) / (NEAR_CLIP - FAR_CLIP); + p[11] = -1.f; + p[14] = 2.f * FAR_CLIP * NEAR_CLIP / (NEAR_CLIP - FAR_CLIP); + + LLMatrix4a mvp; + mvp.setMul(mv, proj); + return mvp; + } + + // Whether @a position lands within the clip square widened by @a margin_x and @a margin_y. + bool inClip(const LLMatrix4a& mvp, const LLVector3& position, F32 margin_x, F32 margin_y) + { + LLVector4a clip; + mvp.transform4(LLVector4a(position.mV[VX], position.mV[VY], position.mV[VZ], 1.f), clip); + const F32 w = clip[3]; + return w > 0.f && fabsf(clip[0]) <= (1.f + margin_x) * w && fabsf(clip[1]) <= (1.f + margin_y) * w; + } + + // Views about the sky: level, up, at the zenith, below the horizon, rolled, wide and narrow, and many at random. + std::vector views() + { + std::vector out = { + { 30.f, 35.f, 0.f, 60.f, 16.f / 9.f, 17.f }, { 200.f, -3.f, 0.f, 60.f, 16.f / 9.f, 245.f }, + { 75.f, 89.f, 0.f, 100.f, 1.5f, 3.f }, { 300.f, -40.f, 0.f, 60.f, 16.f / 9.f, 90.f }, + { 10.f, 20.f, 33.f, 45.f, 1.f, 300.f }, { 160.f, 60.f, -70.f, 120.f, 2.4f, 12.f }, + { 0.f, 0.f, 0.f, 10.f, 16.f / 9.f, 0.f }, + }; + std::mt19937 draw(7); + std::uniform_real_distribution unit(0.f, 1.f); + for (S32 i = 0; i < 40; ++i) + { + out.push_back({ 360.f * unit(draw), -30.f + 120.f * unit(draw), -45.f + 90.f * unit(draw), + 15.f + 100.f * unit(draw), 1.f + 1.5f * unit(draw), 360.f * unit(draw) }); + } + return out; + } +} + +TEST_CASE("alstarfield: the cells lay the points out in order with each star inside its cell's cone", "[alstarfield]") +{ + std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + const ALStarField::Layout layout = ALStarField::layOut(stars); + REQUIRE(layout.mCells.size() == ALStarField::CELLS); + + U32 next = layout.firstPoint(); + U32 empty = 0; + size_t wrong_cell = 0; + size_t outside_cone = 0; + for (U32 c = 0; c < ALStarField::CELLS; ++c) + { + const ALStarField::Cell& cell = layout.mCells[c]; + CAPTURE(c); + REQUIRE(cell.mFirst == next); + next += cell.mCount; + empty += cell.mCount == 0; + CHECK(fabsf(cell.mAxis.length() - 1.f) < 1e-4f); + CHECK(cell.mSinRadius >= 0.f); + CHECK(cell.mSinRadius < 0.5f); + const F32 cos_radius = sqrtf(1.f - cell.mSinRadius * cell.mSinRadius); + for (U32 v = cell.mFirst; v < cell.mFirst + cell.mCount; ++v) + { + const ALStarField::Star& star = stars[v - layout.firstPoint() + layout.mQuads]; + REQUIRE(!ALStarField::spiked(star)); + wrong_cell += ALStarField::cellOf(star.mPosition) != c; + const LLVector3 direction = star.mPosition / star.mPosition.length(); + outside_cone += cell.mAxis * direction < cos_radius - 1e-5f; + } + } + CHECK(next == layout.vertices()); + CHECK(wrong_cell == 0); + CHECK(outside_cone == 0); + // The default field leaves no cell of the sky empty. + CHECK(empty == 0); +} + +TEST_CASE("alstarfield: the cull keeps every cell with a point in view and leaves most of the sky out", "[alstarfield]") +{ + std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + const ALStarField::Layout layout = ALStarField::layOut(stars); + constexpr U32 WIDTH = 1920; + constexpr U32 HEIGHT = 1080; + const F32 margin_x = 2.f * ALStarField::POINT_REACH / (F32)WIDTH; + const F32 margin_y = 2.f * ALStarField::POINT_REACH / (F32)HEIGHT; + + ALStarField::Draws draws; + for (const View& view : views()) + { + CAPTURE(view.mBearing, view.mPitch, view.mRoll, view.mFov, view.mAspect, view.mRotation); + const LLMatrix4a mvp = starMVP(view); + const ALStarField::Frustum frustum = ALStarField::pointFrustum(mvp, WIDTH, HEIGHT); + + size_t lost = 0; + size_t in_view = 0; + for (const ALStarField::Cell& cell : layout.mCells) + { + const bool kept = ALStarField::inView(frustum, cell); + for (U32 v = cell.mFirst; v < cell.mFirst + cell.mCount; ++v) + { + const ALStarField::Star& star = stars[v - layout.firstPoint() + layout.mQuads]; + if (inClip(mvp, star.mPosition, margin_x, margin_y)) + { + ++in_view; + lost += !kept; + } + } + } + CAPTURE(in_view); + CHECK(lost == 0); + + // The draws are the cells kept, in order, the runs that meet merged. + ALStarField::pointDraws(layout, frustum, draws); + REQUIRE(draws.mFirst.size() == draws.mCount.size()); + U32 drawn = 0; + for (size_t r = 0; r < draws.mFirst.size(); ++r) + { + CHECK(draws.mCount[r] > 0); + if (r > 0) + { + CHECK(draws.mFirst[r] > draws.mFirst[r - 1] + draws.mCount[r - 1]); + } + drawn += (U32)draws.mCount[r]; + } + U32 kept_points = 0; + for (const ALStarField::Cell& cell : layout.mCells) + { + kept_points += ALStarField::inView(frustum, cell) ? cell.mCount : 0; + } + CHECK(drawn == kept_points); + CHECK(drawn >= in_view); + } + + // Looking 35 degrees up through 60 degrees at 16:9, the view draws under a third of the field, and the cells it + // draws are mostly in view. + const View level = { 30.f, 35.f, 0.f, 60.f, 16.f / 9.f, 17.f }; + const LLMatrix4a mvp = starMVP(level); + ALStarField::pointDraws(layout, ALStarField::pointFrustum(mvp, WIDTH, HEIGHT), draws); + size_t in_view = 0; + for (size_t p = layout.mQuads; p < stars.size(); ++p) + { + in_view += inClip(mvp, stars[p].mPosition, margin_x, margin_y); + } + const U32 drawn = al_star_test::drawnPoints(draws); + CAPTURE(drawn, in_view, draws.mFirst.size()); + CHECK(drawn * 3 < layout.mPoints); + CHECK(drawn < in_view * 3 / 2); +} diff --git a/indra/llrender/llvertexbuffer.cpp b/indra/llrender/llvertexbuffer.cpp index ad3f63b036..a4dabdbd22 100644 --- a/indra/llrender/llvertexbuffer.cpp +++ b/indra/llrender/llvertexbuffer.cpp @@ -1090,6 +1090,28 @@ void LLVertexBuffer::drawArrays(U32 mode, U32 first, U32 count) const LLRender::sDrawElements += count; } +void LLVertexBuffer::drawMultiArrays(U32 mode, const S32* first, const S32* count, U32 draws) const +{ + llassert(validate_bound_samplers()); + llassert(mGLBuffer == sGLRenderBuffer); + llassert(mGLIndices == sGLRenderIndices); + if (draws == 0) + { + return; + } + + gGL.syncMatrices(); + STOP_GLERROR; + glMultiDrawArrays(sGLMode[mode], first, count, draws); + STOP_GLERROR; + ++LLRender::sDrawCalls; + for (U32 i = 0; i < draws; ++i) + { + llassert(first[i] >= 0 && count[i] >= 0 && (U32)(first[i] + count[i]) <= mNumVerts); + LLRender::sDrawElements += count[i]; + } +} + //static void LLVertexBuffer::initClass(LLWindow* window) { diff --git a/indra/llrender/llvertexbuffer.h b/indra/llrender/llvertexbuffer.h index cecb2c193c..20c4165144 100644 --- a/indra/llrender/llvertexbuffer.h +++ b/indra/llrender/llvertexbuffer.h @@ -272,6 +272,8 @@ class LLVertexBuffer final : public LLRefCount // Detailed error checking can be enabled by setting gDebugGL to true void draw(U32 mode, U32 count, U32 indices_offset) const; void drawArrays(U32 mode, U32 offset, U32 count) const; + // Draws `draws` runs of vertices in one call, run i the count[i] vertices from first[i]. + void drawMultiArrays(U32 mode, const S32* first, const S32* count, U32 draws) const; void drawRange(U32 mode, U32 start, U32 end, U32 count, U32 indices_offset) const; // draw without syncing matrices. If you're positive there have been no matrix diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl index 3f542284e6..5f8a62c943 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl @@ -34,7 +34,7 @@ // drops a point whose centre leaves the screen, which would pop a spiked star's arms at the edge // (LLDrawPoolWLSky::renderStarsDeferred, ALStarField). in vec3 position; // the star's centre, in the stars' frame -in int texture_index; // its colour, in the position's fourth word: rgb = sRGB black-body colour, a = intrinsic intensity +in int texture_index; // its colour, in the position's fourth word: rgb = sRGB black-body colour, a = intensity uniform vec2 screen_res; // the viewport the stars draw into, in pixels #ifdef STAR_QUADS diff --git a/indra/newview/lldrawpoolwlsky.cpp b/indra/newview/lldrawpoolwlsky.cpp index c4bea7c861..ff15d7373d 100644 --- a/indra/newview/lldrawpoolwlsky.cpp +++ b/indra/newview/lldrawpoolwlsky.cpp @@ -338,10 +338,16 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const gSky.mVOWLSkyp->drawStarQuads(); unbind_stars(gDeferredStarQuadProgram); - // Every other star as a point, sized by its vertex shader. + // Every other star as a point, sized by its vertex shader, from the cells the view takes in: its frustum in + // the stars' frame, through the matrices that draw them. + LLMatrix4a mvp; + mvp.setMul(gGL.getModelviewMatrix(), gGL.getProjectionMatrix()); + const ALStarField::Frustum frustum = + ALStarField::pointFrustum(mvp, LLRenderTarget::sCurResX, LLRenderTarget::sCurResY); + LLGLEnable point_size(GL_PROGRAM_POINT_SIZE); bind_stars(gDeferredStarProgram); - gSky.mVOWLSkyp->drawStarPoints(); + gSky.mVOWLSkyp->drawStarPoints(frustum); unbind_stars(gDeferredStarProgram); } diff --git a/indra/newview/llvowlsky.cpp b/indra/newview/llvowlsky.cpp index c73a08734a..d01daedb72 100644 --- a/indra/newview/llvowlsky.cpp +++ b/indra/newview/llvowlsky.cpp @@ -235,12 +235,22 @@ bool LLVOWLSky::updateGeometry(LLDrawable * drawable) return true; } -void LLVOWLSky::drawStarPoints() +void LLVOWLSky::drawStarPoints(const ALStarField::Frustum& frustum) { - if (mStarsVerts.notNull() && mStarLayout.mPoints > 0) + if (mStarsVerts.isNull() || mStarLayout.mPoints == 0) + { + return; + } + + { + LL_PROFILE_ZONE_NAMED("sky stars cull"); + ALStarField::pointDraws(mStarLayout, frustum, mStarDraws); + } + if (!mStarDraws.mFirst.empty()) { mStarsVerts->setBuffer(); - mStarsVerts->drawArrays(LLRender::POINTS, mStarLayout.firstPoint(), mStarLayout.mPoints); + mStarsVerts->drawMultiArrays(LLRender::POINTS, mStarDraws.mFirst.data(), mStarDraws.mCount.data(), + (U32)mStarDraws.mFirst.size()); } } diff --git a/indra/newview/llvowlsky.h b/indra/newview/llvowlsky.h index e3d374904a..b4cfa16264 100644 --- a/indra/newview/llvowlsky.h +++ b/indra/newview/llvowlsky.h @@ -45,8 +45,9 @@ class LLVOWLSky : public LLStaticViewerObject { /*virtual*/ LLDrawable * createDrawable(LLPipeline *pipeline); /*virtual*/ bool updateGeometry(LLDrawable *drawable); - // The stars drawn as points, and the spiked stars as quads (ALStarField::Layout), each with its own program bound. - void drawStarPoints(); + // The stars drawn as points, those of the cells in view through @a frustum, and the spiked stars as quads + // (ALStarField::Layout), each with its own program bound. + void drawStarPoints(const ALStarField::Frustum& frustum); void drawStarQuads(); void drawDome(void); void resetVertexBuffers(void); @@ -77,6 +78,7 @@ class LLVOWLSky : public LLStaticViewerObject { // The star field RenderStarCount asked for, in the order the buffer takes it, and where each kind lies in it. std::vector mStars; ALStarField::Layout mStarLayout; + ALStarField::Draws mStarDraws; // the frame's runs of points, kept for their storage struct MeteorState { From e75ed4cf800a7bb4c2b5879fe2a5e2fea09ee450 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 05:00:49 -0400 Subject: [PATCH 5/7] Draw only the stars bright enough to show this frame At dusk the star brightness (custom_alpha, the sky's star_brightness over 500) falls toward 0, and most of the faint dust adds less to any pixel than the frame can show, yet every star in view was drawn. ALStarField now lays the quads, and each cell's points, out brightest first, and each frame works out the faintest intensity that can still add half a step of 8 bits (0.5/255) to a pixel of an 8-bit attachment: ALStarField::brightest bounds what starsV and starsF give a star of an intensity, every term at its most but the star's own (its colour and extinction at 1, the scintillation and colour jitter at their peaks, +65% and +12%, no moon glare and the moon's wash at its least, the shape at its centre), and it rises with the intensity, so faintestVisible bisects the 256 bytes. Each cell in view then draws only its prefix at that intensity or brighter, and the quads theirs. The pass takes the attachment's gain from the latched shader inputs (mGlobal.mEmissive) and the moon's power from the sky it already reads. The shaders name each bound the arithmetic takes (starsV's STAR_FAINT_PX, STAR_BRIGHT_PX, STAR_SPIKE_START, STAR_SPIKE_PX, STAR_MIN_PX, STAR_TWINKLE_MAX, STAR_CHROMA, STAR_MOON_WASH, STAR_MOON_FADE_MAX and STAR_BLOOM_BOOST, starsF's STAR_HALO and STAR_SPIKE), and brightest reads ALStarField's constants of the same names less STAR_. layOut orders the stars by one counting sort, which is stable: a key for each kind, cell and intensity byte, the spiked stars' first by intensity alone, then each cell's points', brightest first within each, and stars as bright in the field's own order, which is the order stable comparison sorts give. layOut runs on the main thread at login and at every RenderStarCount change, and through the counting sort it takes 3.58 ms at the default count and 17.54 ms at MAX_COUNT, against 14.12 ms and 67.31 ms through those sorts (alstarfield_bench, pinned to one core). Half a step is what a star left out may add. On an 8-bit attachment, the albedo or the emissive without HDR (RGB8), which the stars blend into one at a time, it rounds away and the picture is unchanged. The float one, the emissive under HDR (R11G11B10F), rounds nothing away, and the exposure, the tonemapper and the grade after the sky's resolve may show any of it, so there the pass draws every star in view. A bound for it would have to follow every tonemapper's curve at its live parameters, and the grade; and at dusk the whole field in view costs about 4 us of GPU more than its culled prefix (5.2 us against 1.3 us at a level 60-degree view on NVIDIA). On an 8-bit attachment, at a level 60-degree view the field draws 2.1k to 2.4k of 25.9k points at a star brightness of 0.003, 9.4k to 9.8k at 0.01 and 13.9k to 14.3k at 0.03. From 0.1 up, which takes in the default night's 0.5, every star in view can add half a step and all are drawn, under a full moon too: the moon's wash takes a faint star down to 45% at most, and the default night's faintest dust still adds about 2/255. So moonlit nights at the default brightness draw the whole field in view, and only dusk draws a fraction. alstarfield_test checks that the quads and every cell run brightest first and that the layout's intensities are the stars'; that at every count from 0 to MAX_COUNT the counting sort lays the stars, the runs and, to rounding, the cones out as the stable comparison sorts do; that each STAR_ constant the shaders declare is ALStarField's, and reads starsV and starsF for every term the bound takes; and, through a copy of the shaders' per-star arithmetic over colours, zenith angles, scintillation noises, moon powers, star brightnesses from 0.001 to 2 and both paths, that no star fainter than the threshold reaches half a step, and the faintest kept comes within 30% of it. The draws are each cell in view down to the threshold, and the quads' prefix. alstarfield_gl_test draws every star and then the frame's, with the 8-bit bound on both paths: on the albedo path the pictures are identical in all six scenes; on the emissive path the dusk scenes leave out 14.6k of 29.6k and 21.8k of 55.6k points, and a pixel differs by at most 0.75/255, where two stars left out overlap (8 of 84.5k lit pixels pass half a step). The A/B against the old quads now measures its sub-pixel criterion with every star drawn, and bounds the frame's picture pixel by pixel. With the scintillation's margin left out of the bound, stars left out reach 0.58/255. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/CMakeLists.txt | 1 + indra/alrender/alstarfield.cpp | 149 +++++- indra/alrender/alstarfield.h | 53 ++- indra/alrender/tests/alstarfield_bench.cpp | 59 +++ indra/alrender/tests/alstarfield_gl_test.cpp | 85 +++- indra/alrender/tests/alstarfield_test.cpp | 428 +++++++++++++++++- .../shaders/class1/deferred/starsF.glsl | 9 +- .../shaders/class1/deferred/starsV.glsl | 32 +- indra/newview/lldrawpoolwlsky.cpp | 13 +- indra/newview/llvowlsky.cpp | 11 +- indra/newview/llvowlsky.h | 7 +- 11 files changed, 770 insertions(+), 77 deletions(-) create mode 100644 indra/alrender/tests/alstarfield_bench.cpp diff --git a/indra/alrender/CMakeLists.txt b/indra/alrender/CMakeLists.txt index 1e4d3b3304..af9338d06d 100644 --- a/indra/alrender/CMakeLists.txt +++ b/indra/alrender/CMakeLists.txt @@ -96,6 +96,7 @@ if(AL_BUILD_TESTS) tests/altransienttargetpool_test.cpp tests/alwaterfield_test.cpp tests/alwaterheightbake_test.cpp + BENCH_SOURCES tests/alstarfield_bench.cpp LIBRARIES alrender SUPPORT llrender_test_checks llcommon_test_checks DEFINES ${alrender_shader_dir} diff --git a/indra/alrender/alstarfield.cpp b/indra/alrender/alstarfield.cpp index 0af1e98f32..12613e5735 100644 --- a/indra/alrender/alstarfield.cpp +++ b/indra/alrender/alstarfield.cpp @@ -125,40 +125,66 @@ U32 cellOf(const LLVector3& direction) Layout layOut(std::vector& stars) { - const auto points = std::stable_partition(stars.begin(), stars.end(), spiked); + // The stars in the buffer's order by a counting sort, which is stable: one key for each kind, cell and intensity, + // the spiked stars' first, by intensity alone, then each cell's points', brightest first within each, and stars as + // bright in the field's own order. + constexpr U32 INTENSITIES = 256; + constexpr U32 QUAD_KEYS = INTENSITIES; + constexpr U32 KEYS = QUAD_KEYS + CELLS * INTENSITIES; + + const U32 count = (U32)stars.size(); + std::vector keys(count); + std::vector starts(KEYS + 1, 0); // each key's count, then where its stars start + U32 quads = 0; + for (U32 s = 0; s < count; ++s) + { + const Star& star = stars[s]; + const U32 dimmer = INTENSITIES - 1 - star.mColour.mV[VALPHA]; + U32 key = dimmer; + if (spiked(star)) + { + ++quads; + } + else + { + key = QUAD_KEYS + cellOf(star.mPosition) * INTENSITIES + dimmer; + } + keys[s] = key; + ++starts[key + 1]; + } + for (U32 k = 1; k <= KEYS; ++k) + { + starts[k] += starts[k - 1]; + } Layout layout; - layout.mQuads = (U32)(points - stars.begin()); - layout.mPoints = (U32)(stars.end() - points); + layout.mQuads = quads; + layout.mPoints = count - quads; - // The points by cell. - std::vector cell(layout.mPoints); - std::vector order(layout.mPoints); - for (U32 p = 0; p < layout.mPoints; ++p) + // Where each cell's points start among the points, before the scatter moves the starts on. + std::vector cell_starts(CELLS + 1); + for (U32 c = 0; c < CELLS; ++c) { - cell[p] = cellOf(points[p].mPosition); - order[p] = p; + cell_starts[c] = starts[QUAD_KEYS + c * INTENSITIES] - quads; } - std::stable_sort(order.begin(), order.end(), [&cell](U32 a, U32 b) { return cell[a] < cell[b]; }); - std::vector sorted; - sorted.reserve(layout.mPoints); - for (U32 p : order) + cell_starts[CELLS] = layout.mPoints; + + std::vector sorted(count); + for (U32 s = 0; s < count; ++s) { - sorted.push_back(points[p]); + sorted[starts[keys[s]]++] = stars[s]; } - std::copy(sorted.begin(), sorted.end(), points); + stars.swap(sorted); + const auto points = stars.begin() + quads; // Each cell's run, and the cone its stars' directions lie in: about their mean, as wide as the farthest. layout.mCells.resize(CELLS); - U32 first = 0; for (U32 c = 0; c < CELLS; ++c) { + const U32 first = cell_starts[c]; Cell& run = layout.mCells[c]; run.mFirst = layout.firstPoint() + first; - while (first + run.mCount < layout.mPoints && cell[order[first + run.mCount]] == c) - { - ++run.mCount; - } + run.mCount = cell_starts[c + 1] - first; LLVector3 sum; for (U32 p = first; p < first + run.mCount; ++p) @@ -180,12 +206,80 @@ Layout layOut(std::vector& stars) } run.mSinRadius = least_cos > 0.f ? sqrtf(llmax(0.f, 1.f - least_cos * least_cos)) : 1.f; } - first += run.mCount; } - llassert(first == layout.mPoints); + + layout.mIntensities.reserve(stars.size()); + for (const Star& star : stars) + { + layout.mIntensities.push_back(star.mColour.mV[VALPHA]); + } return layout; } +F32 brightest(F32 intensity, F32 star_alpha, F32 moon_power, bool emissive) +{ + const F32 i = llclamp(intensity, 0.f, 1.f); + + // starsV.glsl's extent: the spikes' strength, the star's ideal width, and the fade of one widened to MIN_PX. + const F32 t = llclamp((i - SPIKE_START) / (1.0f - SPIKE_START), 0.f, 1.f); + const F32 spike = t * t * (3.f - 2.f * t); + const F32 ideal = FAINT_PX + (BRIGHT_PX - FAINT_PX) * i + spike * SPIKE_PX; + const F32 width = llmax(ideal, MIN_PX); + const F32 fade = (ideal * ideal) / (width * width); + + // The scintillation at its peak: twinkle_amount reaches TWINKLE_MAX of the intensity, and the colour jitter + // CHROMA of that. + const F32 twinkle = 1.f + TWINKLE_MAX * i; + const F32 jitter = 1.f + CHROMA * TWINKLE_MAX * i; + + // The moon's glare at its least, none, which leaves its wash the whole of moon_fade. + const F32 wash = 1.f - llmin(llmax(moon_power, 0.f) * (1.f - i) * MOON_WASH, MOON_FADE_MAX); + + // The bloom boost, or the albedo path's gain. + const F32 gain = emissive ? 1.f + i * i * BLOOM_BOOST : 1.f + i; + + // starsF.glsl's shape at the star's centre: the core, the halo and both spikes. + const F32 shape = 1.f + HALO + 2.f * SPIKE * spike; + + return jitter * twinkle * i * llmax(star_alpha, 0.f) * wash * fade * gain * shape; +} + +U32 faintestVisible(F32 star_alpha, F32 moon_power, bool emissive) +{ + // brightest() rises with the intensity, so the first byte to reach LEFT_OUT is found by bisection; 256 if none. + U32 low = 0; + U32 high = 256; + while (low < high) + { + const U32 mid = (low + high) / 2; + if (brightest((F32)mid / 255.f, star_alpha, moon_power, emissive) >= LEFT_OUT) + { + high = mid; + } + else + { + low = mid + 1; + } + } + return low; +} + +namespace +{ + // How many of the run of intensities from @a first, @a count long and brightest first, are at @a faintest or brighter. + U32 drawnOf(const ALStarField::Layout& layout, U32 first, U32 count, U32 faintest) + { + const auto begin = layout.mIntensities.begin() + first; + return (U32)(std::partition_point(begin, begin + count, [faintest](U8 intensity) { return intensity >= faintest; }) + - begin); + } +} + +U32 quadsDrawn(const Layout& layout, U32 faintest) +{ + return drawnOf(layout, 0, layout.mQuads, faintest); +} + void fillVertices(const std::vector& stars, const Layout& layout, LLStrider vertices) { llassert(stars.size() == (size_t)layout.mQuads + layout.mPoints); @@ -257,7 +351,7 @@ bool inView(const Frustum& frustum, const Cell& cell) return true; } -void pointDraws(const Layout& layout, const Frustum& frustum, Draws& draws) +void pointDraws(const Layout& layout, const Frustum& frustum, U32 faintest, Draws& draws) { draws.clear(); for (const Cell& cell : layout.mCells) @@ -266,14 +360,19 @@ void pointDraws(const Layout& layout, const Frustum& frustum, Draws& draws) { continue; } + const U32 drawn = drawnOf(layout, cell.mFirst - layout.firstPoint() + layout.mQuads, cell.mCount, faintest); + if (drawn == 0) + { + continue; + } if (!draws.mFirst.empty() && (U32)(draws.mFirst.back() + draws.mCount.back()) == cell.mFirst) { - draws.mCount.back() += (S32)cell.mCount; + draws.mCount.back() += (S32)drawn; } else { draws.mFirst.push_back((S32)cell.mFirst); - draws.mCount.push_back((S32)cell.mCount); + draws.mCount.push_back((S32)drawn); } } } diff --git a/indra/alrender/alstarfield.h b/indra/alrender/alstarfield.h index 5630d25a25..ecd1b6632b 100644 --- a/indra/alrender/alstarfield.h +++ b/indra/alrender/alstarfield.h @@ -88,10 +88,25 @@ F32 milkyWayDensity(const LLVector3& direction); /// differ, and no other does. std::vector generate(U32 count, F32 dome_radius); +/// The bounds of starsV.glsl's and starsF.glsl's per-star terms, each the shader's constant of the same name with +/// STAR_ before it, which brightest bounds what a star can add by. +constexpr F32 FAINT_PX = 1.8f; ///< the ideal width of a star of intensity 0, in pixels +constexpr F32 BRIGHT_PX = 7.0f; ///< and of intensity 1, before its spikes +constexpr F32 SPIKE_START = 0.90f; ///< the intensity past which a star grows diffraction spikes +constexpr F32 SPIKE_PX = 12.0f; ///< the width the spikes add at intensity 1 +constexpr F32 MIN_PX = 2.6f; ///< the least width a star is drawn at, its brightness spread over the area +constexpr F32 TWINKLE_MAX = 0.65f; ///< the most the scintillation moves a star's brightness, a share of its intensity +constexpr F32 CHROMA = 0.18f; ///< the colour scintillation, a share of the brightness's +constexpr F32 MOON_WASH = 0.55f; ///< how far a moon at full power washes out a star of intensity 0 +constexpr F32 MOON_FADE_MAX = 0.95f; ///< the most the moon's wash and glare dim a star together +constexpr F32 BLOOM_BOOST = 1.2f; ///< the emissive's gain past 1, times the intensity squared +constexpr F32 HALO = 0.06f; ///< the shape's halo at the star's centre, where its core is 1 (starsF.glsl) +constexpr F32 SPIKE = 0.30f; ///< each spike's there at full strength (starsF.glsl) + /// The faintest intensity, as the colour's alpha holds it, that has diffraction spikes: starsV.glsl's -/// smoothstep(0.90, 1.0, intensity) is above 0 past 0.90, and 230 / 255 is the first step past it. Such a star reaches -/// up to 19 pixels across, and GL drops a point whose centre leaves the screen, so its arms would pop at the edge: it -/// is drawn as a quad instead. Every other star is a point, at most 7 pixels across. +/// smoothstep(STAR_SPIKE_START, 1.0, intensity) is above 0 past SPIKE_START, and 230 / 255 is the first step past it. +/// Such a star reaches up to 19 pixels across, and GL drops a point whose centre leaves the screen, so its arms would +/// pop at the edge: it is drawn as a quad instead. Every other star is a point, at most 7 pixels across. constexpr U8 SPIKE_INTENSITY = 230; inline bool spiked(const Star& star) @@ -123,12 +138,14 @@ struct Cell }; /// Where the stars lie in the buffer: the spiked stars' quads first, QUAD_VERTICES vertices each, then every other -/// star, one vertex each, drawn as points, cell by cell. +/// star, one vertex each, drawn as points, cell by cell. The quads, and each cell's points, run brightest first, so the +/// stars a frame can see are a prefix of each run (faintestVisible). struct Layout { U32 mQuads = 0; ///< spiked stars U32 mPoints = 0; ///< the rest std::vector mCells; ///< CELLS of them, in order, each starting where the one before ends + std::vector mIntensities; ///< each star's intensity byte, in the order the stars are laid out U32 firstPoint() const { return mQuads * QUAD_VERTICES; } U32 vertices() const { return firstPoint() + mPoints; } @@ -137,6 +154,30 @@ struct Layout /// Orders @a stars as the buffer lays them out, and says where each kind and each cell lies. Layout layOut(std::vector& stars); +/// A step of 8 bits, and the most a star left out may add to any pixel: half that step. The cull is an 8-bit +/// attachment's, the albedo or the emissive without HDR (RGB8), which the stars blend into a step at a time, so what a +/// star adds under half a step rounds away and leaves the picture as it was. The float one, the emissive under HDR +/// (R11G11B10F), rounds nothing away, and the exposure, the tonemapper and the grade after it may show any of it, so +/// every star in view is drawn there. +constexpr F32 QUANTUM = 1.f / 255.f; +constexpr F32 LEFT_OUT = 0.5f * QUANTUM; + +/// The most a star of intensity @a intensity can add to any pixel, by starsV.glsl and starsF.glsl at their bounds, for a +/// frame's star brightness (custom_alpha), the moon's power over the sky (moon_brightness times the sine of its +/// elevation, 0 below the horizon) and the attachment's gain (the emissive's or the albedo's). Every term is at its most +/// but the star's own: its colour and extinction at 1, its scintillation and colour jitter at their peaks (up to +/// TWINKLE_MAX and CHROMA of that, +65% and +12%), the moon's glare at 0 and its wash at its least, the shape at its +/// centre. It rises with the intensity. +F32 brightest(F32 intensity, F32 star_alpha, F32 moon_power, bool emissive); + +/// The faintest intensity byte that can add LEFT_OUT or more to a pixel of an 8-bit attachment this frame (brightest), +/// so every fainter star may be left out; 256 where none can. +U32 faintestVisible(F32 star_alpha, F32 moon_power, bool emissive); + +/// The spiked stars a frame draws, those at @a faintest or brighter: a prefix of the quads, QUAD_VERTICES vertices +/// each. +U32 quadsDrawn(const Layout& layout, U32 faintest); + /// The most a point reaches past its centre, in pixels: half the widest star drawn as a point (6.5 pixels across, just /// under SPIKE_INTENSITY) and the half pixel starsV.glsl widens each side by. GL drops a point whose centre leaves the /// clip volume, but a view's cull keeps every point this near the edge, so nothing a driver would draw is left out. @@ -171,8 +212,8 @@ struct Draws } }; -/// The points of @a layout that a view through @a frustum draws, into @a draws. -void pointDraws(const Layout& layout, const Frustum& frustum, Draws& draws); +/// The points of @a layout that a view through @a frustum draws, at @a faintest or brighter, into @a draws. +void pointDraws(const Layout& layout, const Frustum& frustum, U32 faintest, Draws& draws); /// The buffer's vertices for @a stars laid out as @a layout, layout.vertices() of them: a star's centre in xyz and its /// colour's four bytes in w, the word where LLVertexBuffer keeps the texture index, so a star is 16 bytes. A quad's diff --git a/indra/alrender/tests/alstarfield_bench.cpp b/indra/alrender/tests/alstarfield_bench.cpp new file mode 100644 index 0000000000..c8221412d9 --- /dev/null +++ b/indra/alrender/tests/alstarfield_bench.cpp @@ -0,0 +1,59 @@ +/** + * @file alstarfield_bench.cpp + * @brief What laying the star field out costs, on the main thread at login and at every RenderStarCount change: the + * default count and the most a count may ask for. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Alchemy Viewer Source Code + * Copyright (C) 2026, Rye + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "../alstarfield.h" + +#include "altestbenchmark.h" + +#include + +#include + +namespace +{ + // LLSettingsSky's DOME_RADIUS. + constexpr F32 DOME_RADIUS = 15000.f; + + TEST_CASE("alstarfield_bench: the field laid out at the default count and the most", "[alstarfield_bench][!benchmark]") + { + ll_test::skipUnlessOptimised(); + for (const U32 count : { ALStarField::DEFAULT_COUNT, ALStarField::MAX_COUNT }) + { + const std::vector field = ALStarField::generate(count, DOME_RADIUS); + // Each run lays out a field of its own, as generated: layOut orders the stars it is given in place. + BENCHMARK_ADVANCED(fmt::format("layOut, {} stars", field.size()))(Catch::Benchmark::Chronometer meter) + { + std::vector> fields(meter.runs(), field); + meter.measure([&](int i) { return ALStarField::layOut(fields[i]).mQuads; }); + }; + BENCHMARK(fmt::format("generate, {} stars", field.size())) + { + return ALStarField::generate(count, DOME_RADIUS).size(); + }; + } + } +} diff --git a/indra/alrender/tests/alstarfield_gl_test.cpp b/indra/alrender/tests/alstarfield_gl_test.cpp index 272e676a3f..19314b7a35 100644 --- a/indra/alrender/tests/alstarfield_gl_test.cpp +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -642,9 +642,15 @@ void main() } // The stars drawn as LLDrawPoolWLSky::renderStarsDeferred draws them, into the G-buffer of @a emissive's path; - // every point, the cull's aside, where @a everything. - Picture drawStars(const Scene& scene, bool emissive, bool everything = false) + // every point, the frustum's cull aside, where @a everything, and every star however faint where + // @a every_star. + Picture drawStars(const Scene& scene, bool emissive, bool everything = false, bool every_star = false) { + { + const F32 moon_power = scene.mMoonBrightness * llmax(0.f, sinf(scene.mMoonElevation * DEG_TO_RAD)); + mQuadsDrawn = ALStarField::quadsDrawn( + mLayout, every_star ? 0 : ALStarField::faintestVisible(scene.mStarAlpha, moon_power, emissive)); + } LLRenderTarget& target = mTarget[emissive]; target.bindTarget(); clearDome(emissive); @@ -663,18 +669,24 @@ void main() quads.bind(); bindUniforms(quads, scene); mStarBuffer->setBuffer(); - mStarBuffer->drawArrays(LLRender::TRIANGLES, 0, mLayout.firstPoint()); + mStarBuffer->drawArrays(LLRender::TRIANGLES, 0, mQuadsDrawn * ALStarField::QUAD_VERTICES); quads.unbind(); LLMatrix4a mvp; mvp.setMul(gGL.getModelviewMatrix(), gGL.getProjectionMatrix()); - ALStarField::pointDraws(mLayout, ALStarField::pointFrustum(mvp, W, H), mDraws); + const F32 moon_power = scene.mMoonBrightness * llmax(0.f, sinf(scene.mMoonElevation * DEG_TO_RAD)); + const U32 faintest = + every_star ? 0 : ALStarField::faintestVisible(scene.mStarAlpha, moon_power, emissive); + ALStarField::pointDraws(mLayout, ALStarField::pointFrustum(mvp, W, H), faintest, mDraws); if (everything) { - mDraws.clear(); - mDraws.mFirst.push_back((S32)mLayout.firstPoint()); - mDraws.mCount.push_back((S32)mLayout.mPoints); - mDraws.mPoints = mLayout.mPoints; + // Every cell, each down to the faintest star the frame keeps. + ALStarField::Frustum whole; + for (LLVector3& normal : whole.mNormals) + { + normal.set(0.f, 0.f, 0.f); + } + ALStarField::pointDraws(mLayout, whole, faintest, mDraws); } LLGLEnable point_size(GL_PROGRAM_POINT_SIZE); @@ -748,6 +760,7 @@ void main() std::vector mLaidOut; ALStarField::Layout mLayout; ALStarField::Draws mDraws; + U32 mQuadsDrawn = 0; LLPointer mOldQuads; LLPointer mStarBuffer; LLPointer mMoonTexture; @@ -762,9 +775,10 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the pictu "[alstarfield_gl]") { // The old quads' corners are snapped to the rasterizer's sub-pixel grid, 1/256 of a pixel, where a point's centre - // is worked exactly; so the old picture moved by one step of that grid is the measure of what may differ. The new - // picture may differ from the old at no more pixels than that, and at no pixel by more than the most it moves any - // pixel, besides the roundings compare() allows. + // is worked exactly; so the old picture moved by one step of that grid is the measure of what may differ. With + // every star drawn, the new picture may differ from the old at no more pixels than that, and at no pixel by more + // than the most it moves any pixel, besides the roundings compare() allows. The brightness cull then leaves out + // stars that add under half a step each (its own test): many pixels at dusk, each by no more than compare() allows. constexpr F32 SUBPIXEL = 1.f / 256.f; setUp(); @@ -778,16 +792,20 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the pictu const Picture blank = drawNothing(emissive); const Picture before = drawOld(scene, emissive); const Picture shifted = drawOld(scene, emissive, true, SUBPIXEL); + const Picture every = drawStars(scene, emissive, false, true); const Picture after = drawStars(scene, emissive); const Difference jitter = compare(before, shifted, blank, attachment); - const Difference d = compare(before, after, blank, attachment, jitter.mMost); + const Difference raster = compare(before, every, blank, attachment, jitter.mMost); + const Difference d = compare(before, after, blank, attachment, jitter.mMost); const std::string summary = d.summary(); + const std::string every_star = raster.summary(); const std::string old_moved = jitter.summary(); - CAPTURE(summary, old_moved); + CAPTURE(summary, every_star, old_moved); UNSCOPED_INFO("the scene shows stars"); CHECK(d.mLit > 100); + CHECK(raster.mBeyond == 0); + CHECK(raster.mDiffer <= jitter.mDiffer); CHECK(d.mBeyond == 0); - CHECK(d.mDiffer <= jitter.mDiffer); if (scene.mMoonBrightness > 0.f) { const Difference moon = compare(before, drawOld(scene, emissive, false), blank, attachment); @@ -822,3 +840,42 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the cull leaves out only } } } + +TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the brightness cull leaves out only stars that add under half a step", + "[alstarfield_gl]") +{ + setUp(); + U32 left_out_somewhere = 0; + for (const Scene& scene : SCENES) + { + for (S32 path = 0; path < 2; ++path) + { + const bool emissive = path == 1; + const S32 attachment = emissive ? 3 : 0; + CAPTURE(scene.mName, emissive); + const Picture blank = drawNothing(emissive); + const Picture every = drawStars(scene, emissive, false, true); + const U32 all_points = al_star_test::drawnPoints(mDraws); + const U32 all_quads = mQuadsDrawn; + const Picture visible = drawStars(scene, emissive); + const U32 points = al_star_test::drawnPoints(mDraws); + const U32 quads = mQuadsDrawn; + // Each star left out adds under half a step to any pixel. On the 8-bit path every blend rounds, so what + // each adds rounds away and the picture is the same; on the emissive path two left out where they overlap + // add together, so a pixel may differ by twice that, and few do by more than once. + const Difference d = compare(every, visible, blank, attachment, emissive ? ALStarField::LEFT_OUT : 0.f); + const Difference once = compare(every, visible, blank, attachment); + const std::string summary = d.summary(); + CAPTURE(summary, once.mBeyond, all_points, points, all_quads, quads); + CHECK(d.mBeyond == 0); + CHECK(once.mBeyond * 1000 <= d.mLit); + if (!emissive) + { + CHECK(d.mDiffer == 0); + } + left_out_somewhere += all_points - points; + } + } + UNSCOPED_INFO("the dusk scenes leave stars out"); + CHECK(left_out_somewhere > 0); +} diff --git a/indra/alrender/tests/alstarfield_test.cpp b/indra/alrender/tests/alstarfield_test.cpp index 1ba4442304..fff2ea5119 100644 --- a/indra/alrender/tests/alstarfield_test.cpp +++ b/indra/alrender/tests/alstarfield_test.cpp @@ -1,7 +1,8 @@ /** * @file alstarfield_test.cpp * @brief The star field: the same field from the same count in every session, laid out in its tiers, and the - * buffer it is drawn from, binned into cells a view culls. + * buffer it is drawn from, binned into cells a view culls, brightest first so a frame draws only the stars it + * can see. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -39,10 +40,13 @@ #include "llvector4a.h" #include +#include #include +#include #include #include #include +#include #include #include #include @@ -279,13 +283,13 @@ TEST_CASE("alstarfield: every star lies on the upper hemisphere in its tier's in TEST_CASE("alstarfield: a star is spiked exactly where starsV.glsl gives it spikes", "[alstarfield]") { - // starsV.glsl's gate, smoothstep(0.90, 1.0, intensity), on the intensity the vertex stage unpacks. + // starsV.glsl's gate, smoothstep(STAR_SPIKE_START, 1.0, intensity), on the intensity the vertex stage unpacks. const std::string vertex = shader_source("class1/deferred/starsV.glsl"); - REQUIRE(vertex.find("smoothstep(0.90, 1.0, intensity)") != std::string::npos); + REQUIRE(vertex.find("smoothstep(STAR_SPIKE_START, 1.0, intensity)") != std::string::npos); for (S32 byte = 0; byte < 256; ++byte) { const F32 intensity = (F32)byte / 255.f; - const F32 t = llclamp((intensity - 0.90f) / (1.0f - 0.90f), 0.f, 1.f); + const F32 t = llclamp((intensity - ALStarField::SPIKE_START) / (1.0f - ALStarField::SPIKE_START), 0.f, 1.f); const F32 spike = t * t * (3.f - 2.f * t); ALStarField::Star star{ LLVector3(0.f, 0.f, 1.f), LLColor4U(255, 255, 255, (U8)byte) }; CAPTURE(byte, spike); @@ -307,16 +311,23 @@ TEST_CASE("alstarfield: the layout puts the spiked stars first and keeps every s CHECK(layout.firstPoint() == layout.mQuads * ALStarField::QUAD_VERTICES); CHECK(layout.vertices() == layout.firstPoint() + layout.mPoints); - // Each part holds its kind: the quads in the field's own order, the points cell by cell, in the field's order - // within a cell. + // Each part holds its kind: the quads brightest first, the points cell by cell and brightest first within a cell, + // stars as bright in the field's own order. std::vector quads; std::vector points; for (const ALStarField::Star& star : field) { (ALStarField::spiked(star) ? quads : points).push_back(star); } - std::stable_sort(points.begin(), points.end(), [](const ALStarField::Star& a, const ALStarField::Star& b) - { return ALStarField::cellOf(a.mPosition) < ALStarField::cellOf(b.mPosition); }); + const auto brighter = [](const ALStarField::Star& a, const ALStarField::Star& b) + { return a.mColour.mV[VALPHA] > b.mColour.mV[VALPHA]; }; + std::stable_sort(quads.begin(), quads.end(), brighter); + std::stable_sort(points.begin(), points.end(), [&brighter](const ALStarField::Star& a, const ALStarField::Star& b) + { + const U32 cell_a = ALStarField::cellOf(a.mPosition); + const U32 cell_b = ALStarField::cellOf(b.mPosition); + return cell_a != cell_b ? cell_a < cell_b : brighter(a, b); + }); REQUIRE(quads.size() == layout.mQuads); size_t differ = 0; for (size_t s = 0; s < stars.size(); ++s) @@ -327,6 +338,109 @@ TEST_CASE("alstarfield: the layout puts the spiked stars first and keeps every s CHECK(differ == 0); } +namespace +{ + // The layout by comparison sorts, stable, from the same field: the spiked stars first and brightest first, then the + // points by cell and brightest first within each, stars as bright in the field's own order; each cell's run and + // cone as layOut works them. + ALStarField::Layout referenceLayOut(std::vector& stars) + { + using namespace ALStarField; + const auto points = std::stable_partition(stars.begin(), stars.end(), spiked); + + Layout layout; + layout.mQuads = (U32)(points - stars.begin()); + layout.mPoints = (U32)(stars.end() - points); + + const auto brighter = [](const Star& a, const Star& b) { return a.mColour.mV[VALPHA] > b.mColour.mV[VALPHA]; }; + std::stable_sort(stars.begin(), points, brighter); + std::stable_sort(points, stars.end(), [&brighter](const Star& a, const Star& b) + { + const U32 cell_a = cellOf(a.mPosition); + const U32 cell_b = cellOf(b.mPosition); + return cell_a != cell_b ? cell_a < cell_b : brighter(a, b); + }); + + layout.mCells.resize(CELLS); + U32 first = 0; + for (U32 c = 0; c < CELLS; ++c) + { + Cell& run = layout.mCells[c]; + run.mFirst = layout.firstPoint() + first; + while (first + run.mCount < layout.mPoints && cellOf(points[first + run.mCount].mPosition) == c) + { + ++run.mCount; + } + LLVector3 sum; + for (U32 p = first; p < first + run.mCount; ++p) + { + sum += points[p].mPosition / points[p].mPosition.length(); + } + if (run.mCount == 0 || sum.normalize() == 0.f) + { + run.mAxis.set(0.f, 0.f, 1.f); + run.mSinRadius = run.mCount == 0 ? 0.f : 1.f; + } + else + { + run.mAxis = sum; + F32 least_cos = 1.f; + for (U32 p = first; p < first + run.mCount; ++p) + { + least_cos = llmin(least_cos, run.mAxis * (points[p].mPosition / points[p].mPosition.length())); + } + run.mSinRadius = least_cos > 0.f ? sqrtf(llmax(0.f, 1.f - least_cos * least_cos)) : 1.f; + } + first += run.mCount; + } + for (const Star& star : stars) + { + layout.mIntensities.push_back(star.mColour.mV[VALPHA]); + } + return layout; + } +} + +TEST_CASE("alstarfield: the layout is the stable sort's at every count", "[alstarfield]") +{ + for (const U32 count : { 0u, 1u, 7u, 2000u, ALStarField::DEFAULT_COUNT / 10, ALStarField::DEFAULT_COUNT, + ALStarField::MAX_COUNT / 2, ALStarField::MAX_COUNT }) + { + CAPTURE(count); + std::vector stars = ALStarField::generate(count, DOME_RADIUS); + std::vector expected_stars = stars; + const ALStarField::Layout layout = ALStarField::layOut(stars); + const ALStarField::Layout expected = referenceLayOut(expected_stars); + + CHECK(layout.mQuads == expected.mQuads); + CHECK(layout.mPoints == expected.mPoints); + REQUIRE(stars.size() == expected_stars.size()); + size_t moved = 0; + for (size_t s = 0; s < stars.size(); ++s) + { + moved += !same(stars[s], expected_stars[s]); + } + CHECK(moved == 0); + CHECK(layout.mIntensities == expected.mIntensities); + // The runs exactly; the cones, summed over the same stars in the same order, to the rounding another + // translation unit's float maths may differ by: the radius by its square, one less the least cosine's square, + // since a cell of one or two stars takes the square root of a rounding. + REQUIRE(layout.mCells.size() == expected.mCells.size()); + size_t runs_differ = 0; + size_t cones_differ = 0; + for (size_t c = 0; c < layout.mCells.size(); ++c) + { + const ALStarField::Cell& a = layout.mCells[c]; + const ALStarField::Cell& b = expected.mCells[c]; + runs_differ += a.mFirst != b.mFirst || a.mCount != b.mCount; + cones_differ += (a.mAxis - b.mAxis).length() > 1e-5f + || fabsf(a.mSinRadius * a.mSinRadius - b.mSinRadius * b.mSinRadius) > 1e-5f; + } + CHECK(runs_differ == 0); + CHECK(cones_differ == 0); + } +} + TEST_CASE("alstarfield: a star's vertex is its centre and its colour's bytes and a quad has six of them", "[alstarfield]") { std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT / 50, DOME_RADIUS); @@ -509,7 +623,7 @@ TEST_CASE("alstarfield: the cull keeps every cell with a point in view and leave CHECK(lost == 0); // The draws are the cells kept, in order, the runs that meet merged. - ALStarField::pointDraws(layout, frustum, draws); + ALStarField::pointDraws(layout, frustum, 0, draws); REQUIRE(draws.mFirst.size() == draws.mCount.size()); U32 drawn = 0; for (size_t r = 0; r < draws.mFirst.size(); ++r) @@ -534,7 +648,7 @@ TEST_CASE("alstarfield: the cull keeps every cell with a point in view and leave // draws are mostly in view. const View level = { 30.f, 35.f, 0.f, 60.f, 16.f / 9.f, 17.f }; const LLMatrix4a mvp = starMVP(level); - ALStarField::pointDraws(layout, ALStarField::pointFrustum(mvp, WIDTH, HEIGHT), draws); + ALStarField::pointDraws(layout, ALStarField::pointFrustum(mvp, WIDTH, HEIGHT), 0, draws); size_t in_view = 0; for (size_t p = layout.mQuads; p < stars.size(); ++p) { @@ -545,3 +659,297 @@ TEST_CASE("alstarfield: the cull keeps every cell with a point in view and leave CHECK(drawn * 3 < layout.mPoints); CHECK(drawn < in_view * 3 / 2); } + +namespace +{ + // What a star adds to the brightest pixel it reaches, worked as starsV.glsl and starsF.glsl work it: its colour + // (sRGB bytes) and intensity byte, the cosine of its zenith angle, its scintillation noises (each 0..1), the moon's + // power and the cosine of the star's angle from it, the star brightness and the path; times the shape at its centre. + F32 shaderPeak(const LLColor4U& colour, F32 cos_zenith, F32 twinkle_base, const LLVector3& chroma_noise, + F32 moon_power, F32 moon_cos, F32 star_alpha, bool emissive) + { + const F32 intensity = (F32)colour.mV[VALPHA] / 255.f; + + using namespace ALStarField; + + const F32 base_px = FAINT_PX * (1.f - intensity) + BRIGHT_PX * intensity; + const F32 t = llclamp((intensity - SPIKE_START) / (1.f - SPIKE_START), 0.f, 1.f); + const F32 spike = t * t * (3.f - 2.f * t); + const F32 ideal_px = base_px + spike * SPIKE_PX; + const F32 quad_px = llmax(ideal_px, MIN_PX); + const F32 subpixel_fade = (ideal_px * ideal_px) / (quad_px * quad_px); + + const F32 cz = llclamp(cos_zenith, 0.02f, 1.0f); + const F32 elev_deg = asinf(cz) * RAD_TO_DEG; + const F32 airmass = 1.0f / (cz + 0.50572f * powf(llmax(6.07995f + elev_deg, 1.0f), -1.6364f)); + const F32 k_ext[3] = { 0.05f, 0.13f, 0.30f }; + + const F32 twinkle_amount = llclamp(0.18f + 0.14f * (airmass - 1.0f), 0.0f, TWINKLE_MAX) * intensity; + const F32 twinkle = 1.0f + (twinkle_base - 0.5f) * 2.0f * twinkle_amount; + const F32 chroma_amt = CHROMA * twinkle_amount; + + const F32 sky_wash = moon_power * (1.0f - intensity) * MOON_WASH; + const F32 direct_glare = powf(llmax(moon_cos, 0.f), 10.0f) * moon_power * 0.85f; + const F32 moon_fade = llclamp(sky_wash + direct_glare, 0.0f, MOON_FADE_MAX); + + const F32 gain = emissive ? 1.0f + intensity * intensity * BLOOM_BOOST : 1.0f + intensity; + const F32 shape = 1.0f + HALO + 2.0f * spike * SPIKE; + + F32 peak = 0.f; + for (S32 c = 0; c < 3; ++c) + { + const F32 linear = powf((F32)colour.mV[c] / 255.f, 2.2f); + const F32 jitter = 1.0f + (chroma_noise.mV[c] - 0.5f) * 2.0f * chroma_amt; + F32 star = linear * jitter * expf(-airmass * k_ext[c]) * twinkle; + star *= intensity * star_alpha * (1.0f - moon_fade) * subpixel_fade; + peak = llmax(peak, star * gain * shape); + } + return peak; + } +} + +TEST_CASE("alstarfield: the quads and every cell's points run brightest first", "[alstarfield]") +{ + std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + const ALStarField::Layout layout = ALStarField::layOut(stars); + REQUIRE(layout.mIntensities.size() == stars.size()); + + size_t mismatched = 0; + for (size_t s = 0; s < stars.size(); ++s) + { + mismatched += layout.mIntensities[s] != stars[s].mColour.mV[VALPHA]; + } + CHECK(mismatched == 0); + + const auto descending = [&layout](size_t first, size_t count) + { + for (size_t s = first + 1; s < first + count; ++s) + { + if (layout.mIntensities[s] > layout.mIntensities[s - 1]) + { + return false; + } + } + return true; + }; + CHECK(descending(0, layout.mQuads)); + size_t unsorted = 0; + for (const ALStarField::Cell& cell : layout.mCells) + { + unsorted += !descending(cell.mFirst - layout.firstPoint() + layout.mQuads, cell.mCount); + } + CHECK(unsorted == 0); +} + +namespace +{ + // The value of the shader constant `const float = ;` in @a source, or NaN where it declares none. + F32 shaderConstant(const std::string& source, const std::string& name) + { + const std::string declaration = "const float " + name; + for (size_t at = source.find(declaration); at != std::string::npos; at = source.find(declaration, at + 1)) + { + size_t next = at + declaration.size(); + if (next < source.size() && (std::isalnum((unsigned char)source[next]) || source[next] == '_')) + { + continue; // a longer name + } + next = source.find_first_not_of(" \t", next); + if (next == std::string::npos || source[next] != '=') + { + continue; + } + return std::strtof(source.c_str() + next + 1, nullptr); + } + return std::numeric_limits::quiet_NaN(); + } +} + +TEST_CASE("alstarfield: the brightness cull's bound is starsV.glsl's and starsF.glsl's", "[alstarfield]") +{ + // The terms ALStarField::brightest bounds: if a shader changes one, the bound has to follow. + const std::string vertex = shader_source("class1/deferred/starsV.glsl"); + const std::string fragment = shader_source("class1/deferred/starsF.glsl"); + + // Each bound the shaders and brightest share, by name. + struct Shared + { + const std::string* mSource; + const char* mName; + F32 mValue; + }; + const Shared SHARED[] = { + { &vertex, "STAR_FAINT_PX", ALStarField::FAINT_PX }, + { &vertex, "STAR_BRIGHT_PX", ALStarField::BRIGHT_PX }, + { &vertex, "STAR_SPIKE_START", ALStarField::SPIKE_START }, + { &vertex, "STAR_SPIKE_PX", ALStarField::SPIKE_PX }, + { &vertex, "STAR_MIN_PX", ALStarField::MIN_PX }, + { &vertex, "STAR_TWINKLE_MAX", ALStarField::TWINKLE_MAX }, + { &vertex, "STAR_CHROMA", ALStarField::CHROMA }, + { &vertex, "STAR_MOON_WASH", ALStarField::MOON_WASH }, + { &vertex, "STAR_MOON_FADE_MAX", ALStarField::MOON_FADE_MAX }, + { &vertex, "STAR_BLOOM_BOOST", ALStarField::BLOOM_BOOST }, + { &fragment, "STAR_HALO", ALStarField::HALO }, + { &fragment, "STAR_SPIKE", ALStarField::SPIKE }, + }; + for (const Shared& shared : SHARED) + { + const F32 value = shaderConstant(*shared.mSource, shared.mName); + CAPTURE(shared.mName, value, shared.mValue); + CHECK(value == shared.mValue); + } + + // And the shaders' terms use them as brightest does. + for (const char* term : { "mix(STAR_FAINT_PX, STAR_BRIGHT_PX, intensity)", + "smoothstep(STAR_SPIKE_START, 1.0, intensity) * STAR_SPIKE_PX", + "max(ideal_px, STAR_MIN_PX)", "(ideal_px * ideal_px) / (quad_pixels * quad_pixels)", + "clamp(0.18 + 0.14 * (airmass - 1.0), 0.0, STAR_TWINKLE_MAX) * intensity", + "1.0 + (twinkle_base - 0.5) * 2.0 * twinkle_amount", "STAR_CHROMA * twinkle_amount", + "1.0 + (chroma_noise - 0.5) * 2.0 * chroma_amt", + "moon_power * (1.0 - intensity) * STAR_MOON_WASH", + "clamp(sky_wash + direct_glare, 0.0, STAR_MOON_FADE_MAX)", + "1.0 + intensity * intensity * STAR_BLOOM_BOOST", "1.0 + intensity;", + "exp(-airmass * k_ext)", "star_color *= custom_alpha", + "vary_spike = smoothstep(STAR_SPIKE_START, 1.0, intensity)" }) + { + CAPTURE(term); + CHECK(vertex.find(term) != std::string::npos); + } + for (const char* term : { "exp(-r2 * 9.0)", "exp(-r2 * 1.8) * STAR_HALO", "* vary_spike * STAR_SPIKE;", + "exp(-abs(corner.y) * 32.0) * ax" }) + { + CAPTURE(term); + CHECK(fragment.find(term) != std::string::npos); + } +} + +TEST_CASE("alstarfield: the brightness cull leaves out only stars that cannot add half a step", "[alstarfield]") +{ + const LLColor4U colours[] = { LLColor4U(255, 255, 255), LLColor4U(255, 0, 0), LLColor4U(180, 205, 255), + LLColor4U(0, 0, 255) }; + const F32 zeniths[] = { 0.02f, 0.1f, 0.35f, 0.7f, 1.0f }; + const F32 noises[] = { 0.f, 0.5f, 1.f }; + const F32 powers[] = { 0.f, 0.25f, 0.6f, 1.f }; + const F32 alphas[] = { 0.001f, 0.004f, 0.01f, 0.03f, 0.08f, 0.2f, 0.5f, 1.f, 2.f }; + + size_t loud = 0; + for (const bool emissive : { false, true }) + { + for (const F32 alpha : alphas) + { + for (const F32 power : powers) + { + const U32 faintest = ALStarField::faintestVisible(alpha, power, emissive); + CAPTURE(emissive, alpha, power, faintest); + REQUIRE(faintest <= 256); + // The peak of every star the cull leaves out, and of the faintest it keeps. + F32 left_out = 0.f; + F32 kept = 0.f; + for (U32 byte = faintest >= 8 ? faintest - 8 : 0; byte <= llmin(faintest, 255u); ++byte) + { + for (LLColor4U colour : colours) + { + colour.mV[VALPHA] = (U8)byte; + for (const F32 zenith : zeniths) + { + for (const F32 twinkle : noises) + { + for (const F32 chroma : noises) + { + const F32 peak = shaderPeak(colour, zenith, twinkle, LLVector3(chroma, chroma, chroma), + power, 0.f, alpha, emissive); + F32& most = byte < faintest ? left_out : kept; + most = llmax(most, peak); + } + } + } + } + } + CAPTURE(left_out * 255.f, kept * 255.f); + CHECK(left_out < ALStarField::LEFT_OUT); + loud += left_out >= ALStarField::LEFT_OUT; + // And the bound is near: the faintest star kept can come within a third of it, so the cull leaves + // out nearly all it could. + if (faintest <= 255) + { + CHECK(kept >= 0.7f * ALStarField::LEFT_OUT); + } + } + } + } + CHECK(loud == 0); + + // The default night (star brightness 250 of 500) keeps even the faintest dust; dusk leaves most of it out. + CHECK(ALStarField::faintestVisible(0.5f, 0.f, true) <= 13); + CHECK(ALStarField::faintestVisible(0.5f, 0.f, false) <= 13); + CHECK(ALStarField::faintestVisible(0.03f, 0.f, true) > 13); + CHECK(ALStarField::faintestVisible(0.f, 0.f, true) == 256); +} + +TEST_CASE("alstarfield: a view draws each cell in view down to the faintest star kept", "[alstarfield]") +{ + std::vector stars = ALStarField::generate(ALStarField::DEFAULT_COUNT, DOME_RADIUS); + const ALStarField::Layout layout = ALStarField::layOut(stars); + const LLMatrix4a mvp = starMVP({ 30.f, 35.f, 0.f, 60.f, 16.f / 9.f, 17.f }); + const ALStarField::Frustum frustum = ALStarField::pointFrustum(mvp, 1920, 1080); + + ALStarField::Draws all; + ALStarField::pointDraws(layout, frustum, 0, all); + const U32 all_points = al_star_test::drawnPoints(all); + for (const U32 faintest : { 0u, 14u, 30u, 100u, 230u, 256u }) + { + CAPTURE(faintest); + ALStarField::Draws draws; + ALStarField::pointDraws(layout, frustum, faintest, draws); + U32 expected = 0; + for (const ALStarField::Cell& cell : layout.mCells) + { + if (!ALStarField::inView(frustum, cell)) + { + continue; + } + for (U32 v = cell.mFirst; v < cell.mFirst + cell.mCount; ++v) + { + expected += layout.mIntensities[v - layout.firstPoint() + layout.mQuads] >= faintest; + } + } + CHECK(al_star_test::drawnPoints(draws) == expected); + // Each run starts a cell's points and draws only stars at the faintest or brighter. + size_t too_faint = 0; + for (size_t r = 0; r < draws.mFirst.size(); ++r) + { + for (S32 v = draws.mFirst[r]; v < draws.mFirst[r] + draws.mCount[r]; ++v) + { + too_faint += layout.mIntensities[(size_t)v - layout.firstPoint() + layout.mQuads] < faintest; + } + } + CHECK(too_faint == 0); + CHECK(al_star_test::drawnPoints(draws) <= all_points); + + U32 quads = 0; + for (U32 q = 0; q < layout.mQuads; ++q) + { + quads += layout.mIntensities[q] >= faintest; + } + CHECK(ALStarField::quadsDrawn(layout, faintest) == quads); + } + CHECK(all_points > 0); + + // At dusk, a view draws a fraction of what the night shows; by a star brightness of 0.1 (the default night is 0.5) + // every star in view can add half a step, the moon's wash or not. + for (const bool emissive : { false, true }) + { + CAPTURE(emissive); + const auto drawn = [&](F32 alpha, F32 moon_power) + { + ALStarField::Draws dusk; + ALStarField::pointDraws(layout, frustum, ALStarField::faintestVisible(alpha, moon_power, emissive), dusk); + return al_star_test::drawnPoints(dusk); + }; + CHECK(drawn(0.003f, 0.f) * 10 < all_points); + CHECK(drawn(0.01f, 0.f) * 5 < all_points * 2); + CHECK(drawn(0.03f, 0.f) * 5 < all_points * 3); + CHECK(drawn(0.03f, 0.8f) < drawn(0.03f, 0.f)); + CHECK(drawn(0.1f, 0.8f) == all_points); + } +} diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl index 4d45c2b664..27ce9d8486 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl @@ -38,6 +38,11 @@ flat in vec3 vary_point; // the star's centre in window coordinates, and ha flat in vec3 vary_star_color; // the star's colour where its shape is 1 (starsV.glsl) flat in float vary_spike; // the diffraction spikes' strength +// The shape's terms at the star's centre, which ALStarField::brightest bounds a star by under the same names less +// STAR_ (alstarfield_test holds the two to each other); the core is 1 there. +const float STAR_HALO = 0.06; // the halo's +const float STAR_SPIKE = 0.30; // each spike's at full strength + void main() { #ifdef STAR_QUADS @@ -54,14 +59,14 @@ void main() // integrate into the HDR/bloom pipeline. float r2 = dot(corner, corner); float core = exp(-r2 * 9.0); - float halo = exp(-r2 * 1.8) * 0.06; + float halo = exp(-r2 * 1.8) * STAR_HALO; // Diffraction spikes on the brightest tail only (starsV.glsl). float ax = 1.0 - abs(corner.x); float ay = 1.0 - abs(corner.y); float spike_h = exp(-abs(corner.y) * 32.0) * ax; float spike_v = exp(-abs(corner.x) * 32.0) * ay; - float spike = (spike_h + spike_v) * vary_spike * 0.30; + float spike = (spike_h + spike_v) * vary_spike * STAR_SPIKE; float shape = core + halo + spike; diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl index 5f8a62c943..ece285a87f 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl @@ -57,6 +57,19 @@ uniform vec3 moonQuadU; uniform vec3 moonQuadV; uniform sampler2D diffuseMap; +// The bounds of the per-star terms. ALStarField::brightest bounds what a star can add from the same values, under the +// same names less STAR_, and alstarfield_test holds the two to each other. +const float STAR_FAINT_PX = 1.8; // the ideal width of a star of intensity 0, in pixels +const float STAR_BRIGHT_PX = 7.0; // and of intensity 1, before its spikes +const float STAR_SPIKE_START = 0.90; // the intensity past which a star grows diffraction spikes +const float STAR_SPIKE_PX = 12.0; // the width the spikes add at intensity 1 +const float STAR_MIN_PX = 2.6; // the least width a star is drawn at, its brightness spread over the area +const float STAR_TWINKLE_MAX = 0.65; // the most the scintillation moves a star's brightness, a share of its intensity +const float STAR_CHROMA = 0.18; // the colour scintillation, a share of the brightness's +const float STAR_MOON_WASH = 0.55; // how far a moon at full power washes out a star of intensity 0 +const float STAR_MOON_FADE_MAX = 0.95; // the most the moon's wash and glare dim a star together +const float STAR_BLOOM_BOOST = 1.2; // the emissive's gain past 1, times the intensity squared + // sRGB -> linear approximation; good enough for stars since BB color is smooth. vec3 srgb_to_linear(vec3 c) { @@ -152,8 +165,8 @@ void main() // brightest ~5% expand to give diffraction spikes room. The smoothstep // ensures the expansion isn't a hard step — spikes fade in smoothly as // intensity approaches 1. - float base_px = mix(1.8, 7.0, intensity); - float spike_px = smoothstep(0.90, 1.0, intensity) * 12.0; + float base_px = mix(STAR_FAINT_PX, STAR_BRIGHT_PX, intensity); + float spike_px = smoothstep(STAR_SPIKE_START, 1.0, intensity) * STAR_SPIKE_PX; float ideal_px = base_px + spike_px; // Sub-pixel stability: faint stars rendered into a <3px quad flicker as @@ -161,8 +174,7 @@ void main() // and compensate by scaling output energy by the area ratio — integrated // brightness stays the same, but the distribution is smooth enough for // the pixel grid to resolve stably. - const float min_px = 2.6; - float quad_pixels = max(ideal_px, min_px); + float quad_pixels = max(ideal_px, STAR_MIN_PX); float subpixel_fade = (ideal_px * ideal_px) / (quad_pixels * quad_pixels); #ifdef STAR_QUADS @@ -233,7 +245,7 @@ void main() // Scintillation is much stronger near the horizon (airmass) and for the // brighter stars our eye actually resolves flickering on. - float twinkle_amount = clamp(0.18 + 0.14 * (airmass - 1.0), 0.0, 0.65) * intensity; + float twinkle_amount = clamp(0.18 + 0.14 * (airmass - 1.0), 0.0, STAR_TWINKLE_MAX) * intensity; float twinkle = 1.0 + (twinkle_base - 0.5) * 2.0 * twinkle_amount; // Subtle color scintillation: each channel samples its own noise with a @@ -242,7 +254,7 @@ void main() valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.7 + 0.0)), valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 1.3 + 5.7)), valueNoise3D(seed * 0.55 + vec3(0.0, 0.0, time * 2.1 + 13.1))); - float chroma_amt = 0.18 * twinkle_amount; + float chroma_amt = STAR_CHROMA * twinkle_amount; vec3 color_jitter = 1.0 + (chroma_noise - 0.5) * 2.0 * chroma_amt; // --- Moon glare ---------------------------------------------------------- @@ -253,10 +265,10 @@ void main() // scattered background), but nothing survives direct glare at a full moon. float moon_up = max(0.0, moon_dir.z); // horizon clip float moon_power = moon_brightness * moon_up; // 0..~1 - float sky_wash = moon_power * (1.0 - intensity) * 0.55; + float sky_wash = moon_power * (1.0 - intensity) * STAR_MOON_WASH; float moon_cos = max(0.0, dot(dir, moon_dir)); float direct_glare = pow(moon_cos, 10.0) * moon_power * 0.85; - float moon_fade = clamp(sky_wash + direct_glare, 0.0, 0.95); + float moon_fade = clamp(sky_wash + direct_glare, 0.0, STAR_MOON_FADE_MAX); // --- Composition --------------------------------------------------------- vec3 star_color = color * color_jitter * extinction * twinkle; @@ -270,7 +282,7 @@ void main() // bloom pyramid with a larger signal than their on-screen dot suggests. // Kept modest so the bloom pass doesn't smear every mid-tier star into a // halo blob. - star_color *= 1.0 + intensity * intensity * 1.2; + star_color *= 1.0 + intensity * intensity * STAR_BLOOM_BOOST; #else // No emissive buffer - the stars go to diffuse. The glow extraction still catches // the bright peaks via thresholding, so scale slightly up to compensate. @@ -282,5 +294,5 @@ void main() // Diffraction spikes on the brightest tail only. Gated tight (0.90..1.0) // so only real stand-out stars get crosses — their quads expand above, // so the spike has room to render. - vary_spike = smoothstep(0.90, 1.0, intensity); + vary_spike = smoothstep(STAR_SPIKE_START, 1.0, intensity); } diff --git a/indra/newview/lldrawpoolwlsky.cpp b/indra/newview/lldrawpoolwlsky.cpp index ff15d7373d..c65e487743 100644 --- a/indra/newview/lldrawpoolwlsky.cpp +++ b/indra/newview/lldrawpoolwlsky.cpp @@ -330,12 +330,21 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const program.unbind(); }; + // On an 8-bit attachment, the faintest star that can still add anything a pixel keeps this frame, for the star + // brightness, the moon's wash and the attachment's gain, and every fainter one is left out: what a star adds + // under half a step rounds away. The float one, the emissive under HDR, rounds nothing away and the exposure, the + // tonemapper and the grade may show any of it, so every star in view is drawn there. + const F32 moon_power = moon_brightness * llmax(0.f, moon_dir.mV[VZ]); + const bool emissive = gPipeline.shaderInputs().mGlobal.mEmissive; + const U32 faintest = + emissive && LLPipeline::hdrEnabled() ? 0 : ALStarField::faintestVisible(star_alpha, moon_power, emissive); + { LL_PROFILE_GPU_ZONE("sky stars"); // The spiked stars, few and bright, as quads (ALStarField::SPIKE_INTENSITY). bind_stars(gDeferredStarQuadProgram); - gSky.mVOWLSkyp->drawStarQuads(); + gSky.mVOWLSkyp->drawStarQuads(faintest); unbind_stars(gDeferredStarQuadProgram); // Every other star as a point, sized by its vertex shader, from the cells the view takes in: its frustum in @@ -347,7 +356,7 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const LLGLEnable point_size(GL_PROGRAM_POINT_SIZE); bind_stars(gDeferredStarProgram); - gSky.mVOWLSkyp->drawStarPoints(frustum); + gSky.mVOWLSkyp->drawStarPoints(frustum, faintest); unbind_stars(gDeferredStarProgram); } diff --git a/indra/newview/llvowlsky.cpp b/indra/newview/llvowlsky.cpp index d01daedb72..2a74b8cc07 100644 --- a/indra/newview/llvowlsky.cpp +++ b/indra/newview/llvowlsky.cpp @@ -235,7 +235,7 @@ bool LLVOWLSky::updateGeometry(LLDrawable * drawable) return true; } -void LLVOWLSky::drawStarPoints(const ALStarField::Frustum& frustum) +void LLVOWLSky::drawStarPoints(const ALStarField::Frustum& frustum, U32 faintest) { if (mStarsVerts.isNull() || mStarLayout.mPoints == 0) { @@ -244,7 +244,7 @@ void LLVOWLSky::drawStarPoints(const ALStarField::Frustum& frustum) { LL_PROFILE_ZONE_NAMED("sky stars cull"); - ALStarField::pointDraws(mStarLayout, frustum, mStarDraws); + ALStarField::pointDraws(mStarLayout, frustum, faintest, mStarDraws); } if (!mStarDraws.mFirst.empty()) { @@ -254,12 +254,13 @@ void LLVOWLSky::drawStarPoints(const ALStarField::Frustum& frustum) } } -void LLVOWLSky::drawStarQuads() +void LLVOWLSky::drawStarQuads(U32 faintest) { - if (mStarsVerts.notNull() && mStarLayout.mQuads > 0) + const U32 quads = ALStarField::quadsDrawn(mStarLayout, faintest); + if (mStarsVerts.notNull() && quads > 0) { mStarsVerts->setBuffer(); - mStarsVerts->drawArrays(LLRender::TRIANGLES, 0, mStarLayout.firstPoint()); + mStarsVerts->drawArrays(LLRender::TRIANGLES, 0, quads * ALStarField::QUAD_VERTICES); } } diff --git a/indra/newview/llvowlsky.h b/indra/newview/llvowlsky.h index b4cfa16264..ea572ad8f5 100644 --- a/indra/newview/llvowlsky.h +++ b/indra/newview/llvowlsky.h @@ -46,9 +46,10 @@ class LLVOWLSky : public LLStaticViewerObject { /*virtual*/ bool updateGeometry(LLDrawable *drawable); // The stars drawn as points, those of the cells in view through @a frustum, and the spiked stars as quads - // (ALStarField::Layout), each with its own program bound. - void drawStarPoints(const ALStarField::Frustum& frustum); - void drawStarQuads(); + // (ALStarField::Layout), each with its own program bound; only those at @a faintest or brighter + // (ALStarField::faintestVisible). + void drawStarPoints(const ALStarField::Frustum& frustum, U32 faintest); + void drawStarQuads(U32 faintest); void drawDome(void); void resetVertexBuffers(void); From 92f07a380808ee64e9c4c5af85a6c643d5e1ff2f Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 05:08:13 -0400 Subject: [PATCH 6/7] Draw the stars into their one attachment The stars blended into all four attachments of the G-buffer: zeros into the albedo (with the emissive buffer) and the specular, (0, 1, 0, SKIP_ATMOS) into the normal, and their colour into the one the sky's colour is in. Only that one changed anything the frame reads: the others' additions were zero, or the normal's y, which nothing reads at the sky (the luminance meter reads only the flags in its w, and SKIP_ATMOS is 0). Each star pixel still read and wrote all four. starsF now writes that output alone, frag_data[3] with the emissive buffer and frag_data[0] without, and the pass narrows the bound G-buffer's draw buffers to it through LLRenderTarget::setDrawAttachment, which sends a fragment output to the attachment of the same index and leaves every other attachment unread and unwritten (and a clear clears that one alone), and gives them all back with setDrawBuffers(). It takes the place of setDrawOutput, which nothing called: it sent one named output into a target's first colour attachment. The pass takes the attachment from the latched shader inputs, and draws no stars into a target without it, which would otherwise take undefined outputs. Under the stars the sky still draws all four, so the attachment's clear colour stays as it is until Phase 3 stops the dome writing the G-buffer. llrendertarget_gl_test draws a four-output program into a four- attachment target narrowed to each attachment in turn: only that attachment takes its output, a clear takes it alone, and setDrawBuffers() restores all four; with the output sent to attachment 0 instead, 12 checks fail. setDrawOutput's case goes with it. alstarfield_gl_test now draws the stars as the pass does and checks every other attachment is exactly as the sky left it; without the narrowing, 12 fail. The A/B against the old stars, as all of Phase 2 now draws them: - albedo path: 77 to 9,062 of 14.2k to 117.3k lit pixels differ, by at most 1/255 to 3/255, in every scene fewer than the old stars themselves change when moved 1/256 of a pixel (352 to 31,376 pixels, 1/255 to 4/255); - emissive path: 4,924 to 35,754 pixels at night, by at most 2/255 to 16/255 at bright overlaps, which is one R11G11B10F step where the stars' blends round in a new order (the old shaders in the new order give the same), again fewer than the moved old stars (13.4k to 63.8k); at dusk 9.3k and 16.7k pixels differ, nearly all by under half a step, from the stars the brightness cull leaves out, at most 0.75/255 where two of them overlap. glslangValidator -l links all eight star programs; a control that renames the emissive output fails the four emissive ones. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/tests/alstarfield_gl_test.cpp | 14 ++- indra/llrender/llrendertarget.cpp | 15 ++- indra/llrender/llrendertarget.h | 15 +-- .../llrender/tests/llrendertarget_gl_test.cpp | 91 ++++++++++++------- .../shaders/class1/deferred/starsF.glsl | 10 +- indra/newview/lldrawpoolwlsky.cpp | 18 +++- 6 files changed, 107 insertions(+), 56 deletions(-) diff --git a/indra/alrender/tests/alstarfield_gl_test.cpp b/indra/alrender/tests/alstarfield_gl_test.cpp index 19314b7a35..0d5b6d0c13 100644 --- a/indra/alrender/tests/alstarfield_gl_test.cpp +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -2,7 +2,8 @@ * @file alstarfield_gl_test.cpp * @brief The stars two ways on the hidden window, from the same fixed-seed field: as they were drawn before their * rework (a quad of six vertices a star, every term worked per fragment, into all four attachments), and as - * LLDrawPoolWLSky draws them now. The two must leave the same picture. + * LLDrawPoolWLSky draws them now (a point a star, a quad for each spiked one, the cells in view, the stars bright + * enough to show, into the one attachment the sky's colour is in). The two must leave the same picture. * * $LicenseInfo:firstyear=2026&license=viewerlgpl$ * Alchemy Viewer Source Code @@ -661,6 +662,7 @@ void main() LLGLDisable srgb(GL_FRAMEBUFFER_SRGB); LLGLDepthTest no_depth(GL_FALSE, GL_FALSE); gGL.setSceneBlendType(LLRender::BT_ADD); + target.setDrawAttachment(emissive ? 3 : 0); gGL.pushMatrix(); gGL.translatef(CAMERA.mV[VX], CAMERA.mV[VY], CAMERA.mV[VZ]); gGL.rotatef(scene.mRotation, 0.f, 0.f, 1.f); @@ -698,6 +700,7 @@ void main() (U32)mDraws.mFirst.size()); points.unbind(); gGL.popMatrix(); + target.setDrawBuffers(); gGL.setSceneBlendType(LLRender::BT_ALPHA); } Picture picture = readBack(target, emissive ? 4 : 3); @@ -806,6 +809,15 @@ TEST_CASE_METHOD(alstarfield_gl_data, "alstarfield_gl: the stars leave the pictu CHECK(raster.mBeyond == 0); CHECK(raster.mDiffer <= jitter.mDiffer); CHECK(d.mBeyond == 0); + // Every other attachment is as the sky left it, where the old stars added into all of them. + for (S32 other = 0; other < (emissive ? 4 : 3); ++other) + { + if (other != attachment) + { + CAPTURE(other); + CHECK(after.mAttachments[other] == blank.mAttachments[other]); + } + } if (scene.mMoonBrightness > 0.f) { const Difference moon = compare(before, drawOld(scene, emissive, false), blank, attachment); diff --git a/indra/llrender/llrendertarget.cpp b/indra/llrender/llrendertarget.cpp index 00b1a2cdb3..f1015ee18d 100644 --- a/indra/llrender/llrendertarget.cpp +++ b/indra/llrender/llrendertarget.cpp @@ -1255,21 +1255,20 @@ void LLRenderTarget::setDrawBuffers(U32 count) } } -void LLRenderTarget::setDrawOutput(U32 output) +void LLRenderTarget::setDrawAttachment(U32 attachment) { llassert(sBoundTarget == this); - llassert(!mTex.empty()); + llassert(attachment < mTex.size()); - // As many outputs as the G-buffer writes; GL guarantees at least eight draw buffers. GLenum drawbuffers[] = { GL_NONE, GL_NONE, GL_NONE, GL_NONE }; - llassert(output < LL_ARRAY_SIZE(drawbuffers)); - output = llmin(output, (U32)LL_ARRAY_SIZE(drawbuffers) - 1); - drawbuffers[output] = GL_COLOR_ATTACHMENT0; + llassert(attachment < LL_ARRAY_SIZE(drawbuffers)); + attachment = llmin(attachment, (U32)LL_ARRAY_SIZE(drawbuffers) - 1); + drawbuffers[attachment] = GL_COLOR_ATTACHMENT0 + attachment; gGL.flush(); - glDrawBuffers(static_cast(output + 1), drawbuffers); + glDrawBuffers(static_cast(attachment + 1), drawbuffers); mDrawBuffers.fill(-1); - mDrawBuffers[output] = 0; + mDrawBuffers[attachment] = (S8)attachment; } bool LLRenderTarget::isBoundInStack() const diff --git a/indra/llrender/llrendertarget.h b/indra/llrender/llrendertarget.h index dd8ae162a1..3505100f28 100644 --- a/indra/llrender/llrendertarget.h +++ b/indra/llrender/llrendertarget.h @@ -236,13 +236,14 @@ class LLRenderTarget // blended geometry drew, and those are what light the billboard afterwards. void setDrawBuffers(U32 count = 0); - // Take the fragment shader's output `output` (frag_data[output]) into the first colour attachment and - // discard every other output. Asserts that this target is currently bound and has a colour attachment; - // the next bindTarget restores the one-to-one mapping. + // Draw into colour attachment `attachment` alone, from the fragment shader's output of the same index + // (frag_data[attachment]): every other output is discarded and every other attachment left as it is, and a clear + // clears that attachment alone. Asserts that this target is currently bound and has the attachment; setDrawBuffers() + // or the next bindTarget restores them all. // - // How a single-attachment target hosts a pass written for a G-buffer: the sky writes its colour to the - // emissive output, frag_data[3], when that buffer is on, and a target of that colour alone takes only it. - void setDrawOutput(U32 output); + // How a G-buffer pass that adds into one attachment leaves the rest unread and unwritten: the stars draw into the + // emissive attachment, or the albedo without it, and nothing else. + void setDrawAttachment(U32 attachment); //get X resolution U32 getWidth() const { return mResX; } @@ -364,7 +365,7 @@ class LLRenderTarget ALTextureSlot::eTextureType mUsage; - // The colour attachment each draw buffer writes, -1 for none, as bindTarget, setDrawBuffers and setDrawOutput + // The colour attachment each draw buffer writes, -1 for none, as bindTarget, setDrawBuffers and setDrawAttachment // last set them: the buffers clearColorBuffers clears, one at a time. std::array mDrawBuffers = { -1, -1, -1, -1 }; diff --git a/indra/llrender/tests/llrendertarget_gl_test.cpp b/indra/llrender/tests/llrendertarget_gl_test.cpp index 6f6e2b7ee2..8f84610de3 100644 --- a/indra/llrender/tests/llrendertarget_gl_test.cpp +++ b/indra/llrender/tests/llrendertarget_gl_test.cpp @@ -468,28 +468,6 @@ namespace REQUIRE(mTarget.allocate(SIZE, SIZE, GL_RGBA8)); } - // What the target holds after the triangle draws into it, its draw buffers set to output `output`, or left as - // bindTarget sets them when it is negative. - LLColor4U drawn(S32 output) - { - mTarget.bindTarget(); - gGL.setClearColor(LLColor4::transparent); - mTarget.clear(); - if (output >= 0) - { - mTarget.setDrawOutput((U32)output); - } - glUseProgram(mProgram); - glBindVertexArray(mVAO); - glDrawArrays(GL_TRIANGLES, 0, 3); - glBindVertexArray(0); - glUseProgram(0); - U8 pixel[4] = { 0 }; - glReadPixels(SIZE / 2, SIZE / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); - mTarget.flush(); - return LLColor4U(pixel[0], pixel[1], pixel[2], pixel[3]); - } - ll_test::SharedGLScope mGL; LLRenderTarget mTarget; LLRenderTarget mInteger; @@ -507,20 +485,69 @@ namespace }; - // setDrawOutput: a single-attachment target takes the output it names and none of the others, and the next - // bindTarget gives it output 0 again. - TEST_CASE_METHOD(llrendertarget_data, "llrendertarget_gl: setDrawOutput sends the named output to a single-attachment target", "[llrendertarget_gl]") + // setDrawAttachment: a four-attachment target takes into the named attachment the output of the same index and + // leaves the others as they were, a clear clears that one alone, and setDrawBuffers() gives every attachment its + // output again. + TEST_CASE_METHOD(llrendertarget_data, "llrendertarget_gl: setDrawAttachment draws one attachment of a G-buffer from its own output", "[llrendertarget_gl]") { setUp(); - UNSCOPED_INFO("bound: output 0"); - CHECK(drawn(-1) == OUTPUT_COLOURS[0]); - for (S32 output = 0; output < 4; ++output) + LLRenderTarget gbuffer; + REQUIRE(gbuffer.allocate(SIZE, SIZE, GL_RGBA8)); + for (S32 a = 1; a < 4; ++a) { - CAPTURE(output); - REQUIRE(drawn(output) == OUTPUT_COLOURS[output]); + REQUIRE(gbuffer.addColorAttachment(GL_RGBA8)); } - UNSCOPED_INFO("rebound: output 0 again"); - CHECK(drawn(-1) == OUTPUT_COLOURS[0]); + const auto read = [](S32 attachment) + { + U8 pixel[4] = { 0 }; + glReadBuffer(GL_COLOR_ATTACHMENT0 + attachment); + glReadPixels(SIZE / 2, SIZE / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + return LLColor4U(pixel[0], pixel[1], pixel[2], pixel[3]); + }; + const LLColor4U cleared(26, 51, 77, 102); + const LLColor4U recleared(0, 0, 0, 0); + for (S32 attachment = 0; attachment < 4; ++attachment) + { + CAPTURE(attachment); + gbuffer.bindTarget(); + gGL.setClearColor(LLColor4(cleared)); + gbuffer.clear(); + gbuffer.setDrawAttachment((U32)attachment); + glUseProgram(mProgram); + glBindVertexArray(mVAO); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glUseProgram(0); + for (S32 a = 0; a < 4; ++a) + { + CAPTURE(a); + CHECK(read(a) == (a == attachment ? OUTPUT_COLOURS[a] : cleared)); + } + gGL.setClearColor(LLColor4(recleared)); + gbuffer.clear(); + for (S32 a = 0; a < 4; ++a) + { + CAPTURE(a); + UNSCOPED_INFO("a clear takes the drawn attachment alone"); + CHECK(read(a) == (a == attachment ? recleared : cleared)); + } + gbuffer.setDrawBuffers(); + glUseProgram(mProgram); + glBindVertexArray(mVAO); + glDrawArrays(GL_TRIANGLES, 0, 3); + glBindVertexArray(0); + glUseProgram(0); + for (S32 a = 0; a < 4; ++a) + { + CAPTURE(a); + UNSCOPED_INFO("restored: every output to its attachment"); + CHECK(read(a) == OUTPUT_COLOURS[a]); + } + glReadBuffer(GL_COLOR_ATTACHMENT0); + gbuffer.flush(); + } + gGL.setClearColor(LLColor4::transparent); + gbuffer.release(); CHECK((U32)glGetError() == (U32)GL_NO_ERROR); } diff --git a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl index 27ce9d8486..ee9a7e5110 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl @@ -78,14 +78,10 @@ void main() // from the draw pool without alpha weirdness. vec3 premult = vary_star_color * shape; - // GBuffer: stars opt out of atmospheric haze (SKIP_ATMOS). - frag_data[1] = vec4(0.0); - frag_data[2] = vec4(0.0, 1.0, 0.0, GBUFFER_FLAG_SKIP_ATMOS); - + // The stars draw into one attachment and leave the rest as the sky wrote them, its flags among them + // (LLDrawPoolWLSky::renderStarsDeferred narrows the G-buffer's draw buffers to it). #if defined(HAS_EMISSIVE) - // With a dedicated emissive buffer the stars live there (bloom source); - // main diffuse stays zero so atmospheric haze doesn't pick them up. - frag_data[0] = vec4(0.0); + // With a dedicated emissive buffer the stars live there (bloom source). frag_data[3] = vec4(premult, shape); #else frag_data[0] = vec4(premult, shape); diff --git a/indra/newview/lldrawpoolwlsky.cpp b/indra/newview/lldrawpoolwlsky.cpp index c65e487743..03af49e804 100644 --- a/indra/newview/lldrawpoolwlsky.cpp +++ b/indra/newview/lldrawpoolwlsky.cpp @@ -245,6 +245,17 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const return; } + // The G-buffer the stars draw into, and their attachment of it: the emissive with the emissive buffer, the albedo + // without it. The fragment stage writes that output alone, so a target without it takes no stars. + const bool emissive = gPipeline.shaderInputs().mGlobal.mEmissive; + const U32 attachment = emissive ? 3 : 0; + LLRenderTarget* gbuffer = LLRenderTarget::getCurrentBoundTarget(); + if (!gbuffer || attachment >= gbuffer->getNumTextures()) + { + llassert(false); + return; + } + gGL.pushMatrix(); gGL.translatef(camPosLocal.mV[0], camPosLocal.mV[1], camPosLocal.mV[2]); // Subtle rotation so fixed patterns drift over long time scales. @@ -335,10 +346,13 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const // under half a step rounds away. The float one, the emissive under HDR, rounds nothing away and the exposure, the // tonemapper and the grade may show any of it, so every star in view is drawn there. const F32 moon_power = moon_brightness * llmax(0.f, moon_dir.mV[VZ]); - const bool emissive = gPipeline.shaderInputs().mGlobal.mEmissive; const U32 faintest = emissive && LLPipeline::hdrEnabled() ? 0 : ALStarField::faintestVisible(star_alpha, moon_power, emissive); + // The stars add into one attachment of the G-buffer, the emissive with the emissive buffer and the albedo without + // it (starsF.glsl), and leave the rest unread and unwritten. + gbuffer->setDrawAttachment(attachment); + { LL_PROFILE_GPU_ZONE("sky stars"); @@ -360,6 +374,8 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const unbind_stars(gDeferredStarProgram); } + gbuffer->setDrawBuffers(); + gGL.popMatrix(); } From f0985ff9db75880efc3dcabfb3ee725099c32f16 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 27 Oct 2026 03:18:01 -0400 Subject: [PATCH 7/7] Mark the star field's guard vertex through its floats, which GCC allows GCC's -Wclass-memaccess, an error under the Linux rows' warnings as errors, refuses a memset over an LLVector4a, whose one member is private. The test sets and compares the guard past the last vertex through getF32ptr() instead, the same sixteen bytes. Co-Authored-By: Claude Opus 5.5 --- indra/alrender/tests/alstarfield_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/indra/alrender/tests/alstarfield_test.cpp b/indra/alrender/tests/alstarfield_test.cpp index fff2ea5119..ec4c82e426 100644 --- a/indra/alrender/tests/alstarfield_test.cpp +++ b/indra/alrender/tests/alstarfield_test.cpp @@ -450,7 +450,7 @@ TEST_CASE("alstarfield: a star's vertex is its centre and its colour's bytes and std::vector vertices(layout.vertices() + 1); // Past the last vertex: nothing may be written there. - std::memset(&vertices.back(), 0xA5, sizeof(LLVector4a)); + std::memset(vertices.back().getF32ptr(), 0xA5, sizeof(LLVector4a)); LLStrider strider = vertices.data(); ALStarField::fillVertices(stars, layout, strider); @@ -472,7 +472,7 @@ TEST_CASE("alstarfield: a star's vertex is its centre and its colour's bytes and CHECK(wrong == 0); U8 untouched[sizeof(LLVector4a)]; std::memset(untouched, 0xA5, sizeof(untouched)); - CHECK(std::memcmp(&vertices.back(), untouched, sizeof(untouched)) == 0); + CHECK(std::memcmp(vertices.back().getF32ptr(), untouched, sizeof(untouched)) == 0); } namespace