diff --git a/indra/alrender/CMakeLists.txt b/indra/alrender/CMakeLists.txt index e81a650191..af9338d06d 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,9 +92,11 @@ 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 + BENCH_SOURCES tests/alstarfield_bench.cpp LIBRARIES alrender SUPPORT llrender_test_checks llcommon_test_checks DEFINES ${alrender_shader_dir} @@ -102,11 +107,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 +122,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..12613e5735 --- /dev/null +++ b/indra/alrender/alstarfield.cpp @@ -0,0 +1,380 @@ +/** + * @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 "llmatrix4a.h" +#include "v3color.h" +#include "v4color.h" + +#include +#include +#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); }); +} + +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) +{ + // 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 = quads; + layout.mPoints = count - quads; + + // 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_starts[c] = starts[QUAD_KEYS + c * INTENSITIES] - quads; + } + cell_starts[CELLS] = layout.mPoints; + + std::vector sorted(count); + for (U32 s = 0; s < count; ++s) + { + sorted[starts[keys[s]]++] = stars[s]; + } + 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); + for (U32 c = 0; c < CELLS; ++c) + { + const U32 first = cell_starts[c]; + Cell& run = layout.mCells[c]; + run.mFirst = layout.firstPoint() + first; + run.mCount = cell_starts[c + 1] - first; + + 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; + } + } + + 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); + + 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]); + } +} + +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, U32 faintest, Draws& draws) +{ + draws.clear(); + for (const Cell& cell : layout.mCells) + { + if (cell.mCount == 0 || !inView(frustum, cell)) + { + 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)drawn; + } + else + { + draws.mFirst.push_back((S32)cell.mFirst); + draws.mCount.push_back((S32)drawn); + } + } +} + +} // namespace ALStarField diff --git a/indra/alrender/alstarfield.h b/indra/alrender/alstarfield.h new file mode 100644 index 0000000000..ecd1b6632b --- /dev/null +++ b/indra/alrender/alstarfield.h @@ -0,0 +1,223 @@ +/** + * @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 "llstrider.h" +#include "llvector4a.h" +#include "stdtypes.h" +#include "v3color.h" +#include "v3math.h" +#include "v4coloru.h" + +#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. +/// +/// 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); + +/// 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(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) +{ + 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; + +/// 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. 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; } +}; + +/// 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. +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, 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 +/// star is written QUAD_VERTICES times. +void fillVertices(const std::vector& stars, const Layout& layout, LLStrider vertices); + +} // 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_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_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 new file mode 100644 index 0000000000..0d5b6d0c13 --- /dev/null +++ b/indra/alrender/tests/alstarfield_gl_test.cpp @@ -0,0 +1,893 @@ +/** + * @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 (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 + * 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 "alstarfield_draws.h" + +#include +#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]; + } + }; + + // 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, 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; + 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 + { + // 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(); + mStarPoints[path].unload(); + mStarQuads[path].unload(); + mTarget[path].release(); + } + mShaders.clearShaderObjects(); + mOldQuads = nullptr; + mStarBuffer = 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()); + } + + // A star program as LLViewerShaderMgr builds it: the points', or the spiked stars' quads'. + void buildStars(LLGLSLShader& program, bool emissive, bool quads) + { + 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"); + } + 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, 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(); + + // 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(); + + 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(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)); + 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, 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); + 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); + p[8] = -shift * 2.f / (F32)W; + p[9] = -shift / (F32)H; + 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, 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, shift); + { + 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; + // 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); + 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); + 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); + // 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, mQuadsDrawn * ALStarField::QUAD_VERTICES); + quads.unbind(); + + LLMatrix4a mvp; + mvp.setMul(gGL.getModelviewMatrix(), gGL.getProjectionMatrix()); + 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) + { + // 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); + LLGLSLShader& points = mStarPoints[emissive]; + points.bind(); + bindUniforms(points, scene); + mStarBuffer->setBuffer(); + mStarBuffer->drawMultiArrays(LLRender::POINTS, mDraws.mFirst.data(), mDraws.mCount.data(), + (U32)mDraws.mFirst.size()); + points.unbind(); + gGL.popMatrix(); + target.setDrawBuffers(); + 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. 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; + 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; + bool beyond = false; + for (S32 c = 0; c < 3; ++c) + { + const F32 diff = fabsf(pa[c] - pb[c]); + most = llmax(most, diff); + lit = lit || pa[c] != pn[c] || pb[c] != pn[c]; + 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; + d.mPastHalfQuantum += most >= HALF_QUANTUM; + d.mBeyond += beyond; + d.mLit += lit; + } + } + return d; + } + + ll_test::SharedGLScope mGL; + ll_test::TestShaderDir mTree; + ll_test::TestShaderMgr mShaders; + std::vector mField; + std::vector mLaidOut; + ALStarField::Layout mLayout; + ALStarField::Draws mDraws; + U32 mQuadsDrawn = 0; + LLPointer mOldQuads; + LLPointer mStarBuffer; + LLPointer mMoonTexture; + LLGLSLShader mOld[2]; + LLGLSLShader mStarPoints[2]; + LLGLSLShader mStarQuads[2]; + LLRenderTarget mTarget[2]; + }; +} + +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. 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(); + 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 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 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, 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); + // 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); + CAPTURE(moon.mDiffer); + UNSCOPED_INFO("the moon hides stars"); + CHECK(moon.mDiffer > 10); + } + } + } +} + +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); + } + } +} + +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 new file mode 100644 index 0000000000..ec4c82e426 --- /dev/null +++ b/indra/alrender/tests/alstarfield_test.cpp @@ -0,0 +1,955 @@ +/** + * @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, brightest first so a frame draws only the stars it + * can see. + * + * $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 "alstarfield_draws.h" + +#include "llmatrix4a.h" +#include "llrand.h" +#include "llstrider.h" +#include "llvector4a.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#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; + } + + // 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]") +{ + 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; + } +} + +TEST_CASE("alstarfield: a star is spiked exactly where starsV.glsl gives it spikes", "[alstarfield]") +{ + // 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(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 - 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); + 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: 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); + } + 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) + { + const ALStarField::Star& expected = s < quads.size() ? quads[s] : points[s - quads.size()]; + differ += !same(stars[s], expected); + } + 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); + 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().getF32ptr(), 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().getF32ptr(), 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, 0, 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), 0, 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); +} + +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/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/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/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 84bd8c7186..ee9a7e5110 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsF.glsl @@ -30,184 +30,60 @@ 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); -} +#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 -// 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)); -} +// 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 + 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; + float halo = exp(-r2 * 1.8) * STAR_HALO; - // 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. - 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; + // 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 * STAR_SPIKE; 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; - // 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; - - // 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); + vec3 premult = vary_star_color * shape; + // 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); - frag_data[3] = vec4(premult * bloom_boost, shape); + // With a dedicated emissive buffer the stars live there (bloom source). + 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..ece285a87f 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/starsV.glsl @@ -29,17 +29,46 @@ // 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 = intensity -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) +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 + +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; + +// 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) @@ -47,22 +76,97 @@ 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() { - // 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 + // 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); +#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. 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 // 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 @@ -70,15 +174,27 @@ 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); + float quad_pixels = max(ideal_px, STAR_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 @@ -91,15 +207,92 @@ void main() #endif gl_Position = clip_center; +#ifdef STAR_QUADS + vary_corner = corner; +#endif - 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(star.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, 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 + // 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 = STAR_CHROMA * 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) * 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, STAR_MOON_FADE_MAX); + + // --- 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 * 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. + 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 — their quads expand above, + // so the spike has room to render. + vary_spike = smoothstep(STAR_SPIKE_START, 1.0, intensity); } diff --git a/indra/newview/lldrawpoolwlsky.cpp b/indra/newview/lldrawpoolwlsky.cpp index 82db119917..03af49e804 100644 --- a/indra/newview/lldrawpoolwlsky.cpp +++ b/indra/newview/lldrawpoolwlsky.cpp @@ -245,7 +245,16 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const return; } - gDeferredStarProgram.bind(); + // 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]); @@ -253,16 +262,8 @@ void LLDrawPoolWLSky::renderStarsDeferred(const LLVector3& camPosLocal) const 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 +275,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 +284,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 +303,78 @@ 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(); + }; + + // 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 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"); - 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(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 + // 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(frustum, faintest); + unbind_stars(gDeferredStarProgram); } - gDeferredStarProgram.unbind(); + gbuffer->setDrawBuffers(); 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 ee1a0a1bf0..2a74b8cc07 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,6 @@ 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 - // RenderStarCount asked for at the last initStars() call (after - // proportional scaling and rounding). - return (U32)mStarPositions.size(); } LLVOWLSky::LLVOWLSky(const LLUUID &id, const LLPCode pcode, LLViewerRegion *regionp) @@ -426,13 +235,32 @@ bool LLVOWLSky::updateGeometry(LLDrawable * drawable) return true; } -void LLVOWLSky::drawStars(void) +void LLVOWLSky::drawStarPoints(const ALStarField::Frustum& frustum, U32 faintest) { - // render the stars as a sphere centered at viewer camera - if (mStarsVerts.notNull()) + if (mStarsVerts.isNull() || mStarLayout.mPoints == 0) + { + return; + } + + { + LL_PROFILE_ZONE_NAMED("sky stars cull"); + ALStarField::pointDraws(mStarLayout, frustum, faintest, mStarDraws); + } + if (!mStarDraws.mFirst.empty()) { mStarsVerts->setBuffer(); - mStarsVerts->drawArrays(LLRender::TRIANGLES, 0, (S32)(getStarsNumVerts() * 6)); + mStarsVerts->drawMultiArrays(LLRender::POINTS, mStarDraws.mFirst.data(), mStarDraws.mCount.data(), + (U32)mStarDraws.mFirst.size()); + } +} + +void LLVOWLSky::drawStarQuads(U32 faintest) +{ + const U32 quads = ALStarField::quadsDrawn(mStarLayout, faintest); + if (mStarsVerts.notNull() && quads > 0) + { + mStarsVerts->setBuffer(); + mStarsVerts->drawArrays(LLRender::TRIANGLES, 0, quads * ALStarField::QUAD_VERTICES); } } @@ -465,54 +293,12 @@ 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()); + mStarLayout = ALStarField::layOut(mStars); } bool LLVOWLSky::updateStarGeometry(LLDrawable *drawable) @@ -527,15 +313,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; @@ -545,49 +326,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; } - 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). - // 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 LLVector3& center = mStarPositions[s]; - const LLColor4U color_u(mStarColors[s]); - - for (U32 v = 0; v < 6; ++v) - { - *(verticesp++) = center; - *(colorsp++) = color_u; - *(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; @@ -623,7 +374,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 +407,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 +415,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 +426,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 +447,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..ea572ad8f5 100644 --- a/indra/newview/llvowlsky.h +++ b/indra/newview/llvowlsky.h @@ -32,12 +32,11 @@ #include "llviewerobject.h" +#include "alstarfield.h" + #include class LLVOWLSky : public LLStaticViewerObject { -private: - U32 getStarsNumVerts(void) const; - public: LLVOWLSky(const LLUUID &id, const LLPCode pcode, LLViewerRegion *regionp); @@ -46,7 +45,11 @@ class LLVOWLSky : public LLStaticViewerObject { /*virtual*/ LLDrawable * createDrawable(LLPipeline *pipeline); /*virtual*/ bool updateGeometry(LLDrawable *drawable); - void drawStars(void); + // 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; 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); @@ -73,9 +76,10 @@ 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, 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 {