Skip to content

Sky modernization 2: the star field as points, culled by cell and brightness - #582

Merged
RyeMutt merged 7 commits into
rye/sky-p0-p1from
rye/sky-p2-stars
Oct 27, 2026
Merged

RyeMutt merged 7 commits into
rye/sky-p0-p1from
rye/sky-p2-stars

Conversation

@RyeMutt

@RyeMutt RyeMutt commented Oct 27, 2026

Copy link
Copy Markdown
Member

Phase 2 of the sky modernization (doc/SKY_MODERNIZATION.md): the star field. Stacked on #581 (rye/sky-p0-p1).

What changes

  • Generation moves into alrender, as ALStarField. It is the same field Phase 0 lays out, from per-star generators, and the band's density is a template in alstarfieldgenerate.h.
  • Per-star terms move to the vertex stage. Every term that depends only on the star is worked out once per vertex.
  • One point per star. Each star is one 16 B GL_POINTS vertex instead of a six-vertex quad: 155k stars go from 26 MB to 2.5 MB.
    • The 295 stars with diffraction spikes draw as quads in a second draw (D4).
    • Point shapes come from gl_FragCoord, so the driver's point-size rounding doesn't move them.
  • Sky cells. The field is binned into 512 cells, and only the cells whose cone meets the view's frustum draw, through LLVertexBuffer::drawMultiArrays. A level 60° view draws 25.9k of 154.7k points.
  • Brightness cull. Quads and cells are laid out brightest first, and each frame draws only the prefix that can still add half an 8-bit step.
    • This applies on 8-bit attachments only: the albedo, and the emissive without HDR.
    • With HDR and the emissive buffer (R11G11B10F), every star in view draws, since the exposure, tonemapper and grade may show anything.
    • layOut's sort is now a counting sort: 3.6 ms instead of 14.1 ms at the default count.
  • One attachment. Stars draw into their one G-buffer attachment only (LLRenderTarget::setDrawAttachment).

Verified

  • The phase tip built and passed ctest -LE manual on the stack's previous base. On the current base only the full stack's tip was rebuilt: it builds clean and passes 557/557.
  • alstarfield_test and alstarfield_gl_test cover:
    • the layout;
    • the cull, against the shaders' own per-star arithmetic;
    • an A/B against the frozen old star shaders.

Not verified

  • Not run in-world.
  • No Tracy captures.
  • No macOS, AMD or Intel runs.

🤖 Generated with Claude Code

RyeMutt and others added 6 commits October 26, 2026 20:02
LLVOWLSky::initStars laid the field out inline, from the fixed seed
Phase 0 gave it. The star rework that follows bins, sorts and culls that
field, and its tests need to build the same field the viewer draws
without a viewer, so the generation moves to alrender as ALStarField:
generate(count, dome radius) returns each star's centre and its colour
already packed as the vertex stage takes it (the black-body sRGB in rgb,
the intensity in alpha, rounded as LLColor4U rounds an LLColor4). The
draws are made in the same order from the same generators, the
fixed-seed std::mt19937_64 drawing once a star the seed of the star's
own SplitMix64, so the field is the one Phase 0 laid out. Phase 0's
code and ALStarField's are compiled apart, each inlining the band's
density its own way, so where they round one of a star's band
comparisons the other way, as expf may, that star differs, and no
other. The black-body colour and the hemisphere sampling move with it;
the meteors still use both, drawing from the global generator as
before.

How the field is drawn is a template over the Milky Way band's density,
ALStarField::detail::generate(count, radius, density) in
alstarfieldgenerate.h: generate calls it with milkyWayDensity, the
band's Gaussian about the galactic plane, and the tests with densities
of their own.

alstarfield_test pins five stars of the default field across all three
tiers, so a change of seed, draw order or tier shows, and checks that
the field does not move with the global generator or a count in between,
that each tier scales with the count, and that every star sits on the
upper hemisphere in its tier's distance and intensity range. Through
detail::generate it forces one band comparison the other way in each
tier and checks that the star alone moves; with one stream feeding
every star, the same flips move 15,387, 13,712 and 3,976 of a
tenth-size field's 15,500 stars. generate's own field, its density
compiled apart from the test's, differs from the test's at no more
than one star in a thousand.

alstarfield_gl_test draws the stars two ways on the hidden window from
that field: through the star shaders as they are now, copied into the
test as the reference the rework must keep and built as the viewer
builds its own, by createShader from a tree of the viewer's class1
shaders with them beside, the engine's matrix blocks spliced in where
they say //[ENGINE_BLOCK Matrices]; and through the product's
deferred/starsV.glsl and starsF.glsl as LLViewerShaderMgr builds them,
into a G-buffer of each path (four attachments with the emissive
buffer, three without). At this commit the two are the same shaders,
and the pictures match exactly in all six scenes (night looking up, at
the horizon, wide at the zenith, moonlit with the moon in view, dusk,
dusk with the moon up) on both paths. The moonlit scenes check that the
moon's mask hides stars at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
starsF ran, for every fragment of every star's quad, five value noises
(forty hashes), the Kasten-Young airmass (an asin and a pow), the moon's
glare and wash, and the moon mask's texture tap, though every one of
them reads only the star's direction, which is the same at all six of
its vertices. They move to starsV: the extinction, the scintillation
and its colour jitter, the moon's glare and wash, the subpixel fade and
both paths' gains fold into one flat colour, vary_star_color, and the
spikes' strength into vary_spike. The fragment stage keeps only the
shape (core, halo and spikes) times that colour.

The moon's mask was a per-fragment discard of a per-star test, so it
always took a star whole; starsV now leaves such a star out before it
is rasterized, by placing it outside the clip volume, and reads the
moon's texture with textureLod as starsF did.

alstarfield_gl_test now bounds each pixel of the stars' attachment by
one step of its storage (8 bits, or R11G11B10F's mantissa at the
pixel's value) plus half a step of 8 bits, and reports each scene. Here
0 to 5 pixels of 15k to 133k lit ones differ per scene and path, each
by one storage step, where the reordered arithmetic rounds the other
way; nothing passes the bound. With the vertex stage's moon cull taken
out, both moonlit scenes fail on both paths (up to 128/255 over 927 to
1458 pixels). glslangValidator -l links all four programs (emissive
and not, reverse-Z and not) with their attached objects; a control
that renames a varying fails all four.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every star was a quad of six non-indexed vertices of 28 bytes each:
155k stars made 930k vertices and 26 MB. Now a star is one GL_POINTS
vertex of 16 bytes: its centre in xyz and its colour's four bytes in
the position's fourth word, where LLVertexBuffer keeps the texture
index (MAP_VERTEX | MAP_TEXTURE_INDEX), unpacked by unpackUnorm4x8 in
starsV. The default field's buffer is 2.5 MB.

GL drops a point whose centre leaves the screen, which would pop the
arms of the stars with diffraction spikes (up to 19 pixels across) at
the edge, so those draw as quads in a second, small draw (D4): the 295
stars of the default field whose intensity byte is 230 or more, the
first past starsV's smoothstep(0.90, 1.0, intensity). ALStarField
lays the buffer out with their quads first, six vertices each, from
vertex 0, so the quad program (gDeferredStarQuadProgram, starsV and
starsF under STAR_QUADS) takes each corner from gl_VertexID % 6; then
the rest, one vertex each.

A point is sized by gl_PointSize under GL_PROGRAM_POINT_SIZE, but GL
rounds that size to its granularity (0.125 pixels on NVIDIA) and
measures gl_PointCoord across the rounded square, which scaled a
2.6-pixel star's shape by up to 5% and, in the A/B below, changed more
pixels than this does. So starsV passes the star's centre in window
coordinates and half its extent, draws the point a pixel wider than
the star, and starsF works each pixel's offset from the centre from
gl_FragCoord and leaves out the pixels past the star's square: the same
pixels as the quad and the same shape, whatever the driver's
granularity. The viewport that maps the centre is the bound target's
(LLRenderTarget::sCurResX/Y), which is also what now sizes the quads.

alstarfield_test pins SPIKE_INTENSITY to starsV's gate for every
intensity byte and checks that the layout puts every spiked star
first, keeps the field's order within each kind, and writes each
star's centre and colour bytes, six times for a quad and once for a
point, and nothing past the end.

The A/B against the old quads now measures what may differ by the old
picture itself moved one step of the rasterizer's sub-pixel grid
(1/256 of a pixel), since the old quads' corners are snapped to it
where a point's centre is exact. The new picture differs from the old
at 1.8 to 4.8 times fewer pixels than that moved picture does in every
scene and path, by at most 3/255 on the albedo path (the moved old
picture, 4/255) and, on the emissive path, by at most one R11G11B10F
step more, which is the stars' blends rounding in a different order
where they overlap (the old shaders in the new order alone give that).
The plain gl_PointCoord form fails the A/B. glslangValidator -l links
all eight programs (points and quads, emissive and not, reverse-Z and
not); a control that renames the quads' corner output fails the four
quad programs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every star was drawn every frame wherever the camera looked, though a
normal field of view takes in about a sixth of the upper hemisphere.
ALStarField now bins the points (not the 295 spiked quads, which stay
one small draw) into 512 cells, 16 bands of equal height in z, so of
equal area, each cut into 32 arcs of azimuth, and lays the buffer out
cell by cell. Each cell keeps its run of vertices and a cone about the
camera holding every one of its stars' directions: about their mean,
as wide as the farthest.

Each frame the star draw takes the side planes of its own
modelview-projection, which already turns the agent's frame into the
stars' (the translation to the camera and the slow turn about z), so
the cull runs in the stars' frame with no rotation of its own. A star
is thousands of times farther than the camera is from the stars'
origin, so a direction is in view where each plane's normal makes a
non-negative product with it, and a cell is out where one plane has
its whole cone outside. The planes are moved out by POINT_REACH (4
pixels: half the widest point and the pixel starsV widens it by), so
no point a driver would draw from a centre just off the edge is lost.
The cells in view become glMultiDrawArrays runs, those that meet
merged, through LLVertexBuffer::drawMultiArrays, which syncs the
matrices and counts the draw as drawArrays does. The cull is a CPU
zone, "sky stars cull".

alstarfield_test checks that the cells lay the points out in order,
each star in the cell cellOf names and inside its cell's cone, no cell
of the default field empty; that over 47 views (level, up, at the
zenith, below the horizon, rolled, from 10 to 120 degrees, and 40 at
random) every point that lands within the widened clip square is in a
cell the cull keeps, and the draws are exactly the kept cells; and
that looking 35 degrees up through 60 degrees at 16:9 the view draws
25,902 of the 154,705 points, 6.0 times fewer, in 17 runs, where
19,182 land in view.

alstarfield_gl_test draws every point and then only the culled runs:
the pictures are identical in all six scenes on both paths, while the
views draw 22k to 84k of the 155k points. The A/B against the old
quads is unchanged. With the cone's radius left out of the test, views
lose points in view and the culled pictures lose stars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At dusk the star brightness (custom_alpha, the sky's star_brightness
over 500) falls toward 0, and most of the faint dust adds less to any
pixel than the frame can show, yet every star in view was drawn.
ALStarField now lays the quads, and each cell's points, out brightest
first, and each frame works out the faintest intensity that can still
add half a step of 8 bits (0.5/255) to a pixel of an 8-bit attachment:
ALStarField::brightest bounds what starsV and starsF give a star of an
intensity, every term at its most but the star's own (its colour and
extinction at 1, the scintillation and colour jitter at their peaks,
+65% and +12%, no moon glare and the moon's wash at its least, the shape
at its centre), and it rises with the intensity, so faintestVisible
bisects the 256 bytes. Each cell in view then draws only its prefix at
that intensity or brighter, and the quads theirs. The pass takes the
attachment's gain from the latched shader inputs (mGlobal.mEmissive)
and the moon's power from the sky it already reads. The shaders name
each bound the arithmetic takes (starsV's STAR_FAINT_PX, STAR_BRIGHT_PX,
STAR_SPIKE_START, STAR_SPIKE_PX, STAR_MIN_PX, STAR_TWINKLE_MAX,
STAR_CHROMA, STAR_MOON_WASH, STAR_MOON_FADE_MAX and STAR_BLOOM_BOOST,
starsF's STAR_HALO and STAR_SPIKE), and brightest reads ALStarField's
constants of the same names less STAR_.

layOut orders the stars by one counting sort, which is stable: a key
for each kind, cell and intensity byte, the spiked stars' first by
intensity alone, then each cell's points', brightest first within each,
and stars as bright in the field's own order, which is the order stable
comparison sorts give. layOut runs on the main thread at login and at
every RenderStarCount change, and through the counting sort it takes
3.58 ms at the default count and 17.54 ms at MAX_COUNT, against
14.12 ms and 67.31 ms through those sorts (alstarfield_bench, pinned to
one core).

Half a step is what a star left out may add. On an 8-bit attachment, the
albedo or the emissive without HDR (RGB8), which the stars blend into
one at a time, it rounds away and the picture is unchanged. The float
one, the emissive under HDR (R11G11B10F), rounds nothing away, and the
exposure, the tonemapper and the grade after the sky's resolve may show
any of it, so there the pass draws every star in view. A bound for it
would have to follow every tonemapper's curve at its live parameters,
and the grade; and at dusk the whole field in view costs about 4 us of
GPU more than its culled prefix (5.2 us against 1.3 us at a level
60-degree view on NVIDIA).

On an 8-bit attachment, at a level 60-degree view the field draws 2.1k
to 2.4k of 25.9k points at a star brightness of 0.003, 9.4k to 9.8k at
0.01 and 13.9k to 14.3k at 0.03. From 0.1 up, which takes in the
default night's 0.5, every star in view can add half a step and all are
drawn, under a full moon too: the moon's wash takes a faint star down
to 45% at most, and the default night's faintest dust still adds about
2/255. So moonlit nights at the default brightness draw the whole field
in view, and only dusk draws a fraction.

alstarfield_test checks that the quads and every cell run brightest
first and that the layout's intensities are the stars'; that at every
count from 0 to MAX_COUNT the counting sort lays the stars, the runs
and, to rounding, the cones out as the stable comparison sorts do; that
each STAR_ constant the shaders declare is ALStarField's, and reads
starsV and starsF for every term the bound takes; and, through a copy
of the shaders' per-star arithmetic over colours, zenith angles,
scintillation noises, moon powers, star brightnesses from 0.001 to 2
and both paths, that no star fainter than the threshold reaches half a
step, and the faintest kept comes within 30% of it. The draws are each
cell in view down to the threshold, and the quads' prefix.

alstarfield_gl_test draws every star and then the frame's, with the
8-bit bound on both paths: on the albedo path the pictures are
identical in all six scenes; on the emissive path the dusk scenes leave
out 14.6k of 29.6k and 21.8k of 55.6k points, and a pixel differs by at
most 0.75/255, where two stars left out overlap (8 of 84.5k lit pixels
pass half a step). The A/B against the old quads now measures its
sub-pixel criterion with every star drawn, and bounds the frame's
picture pixel by pixel. With the scintillation's margin left out of the
bound, stars left out reach 0.58/255.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stars blended into all four attachments of the G-buffer: zeros
into the albedo (with the emissive buffer) and the specular, (0, 1, 0,
SKIP_ATMOS) into the normal, and their colour into the one the sky's
colour is in. Only that one changed anything the frame reads: the
others' additions were zero, or the normal's y, which nothing reads at
the sky (the luminance meter reads only the flags in its w, and
SKIP_ATMOS is 0). Each star pixel still read and wrote all four.

starsF now writes that output alone, frag_data[3] with the emissive
buffer and frag_data[0] without, and the pass narrows the bound
G-buffer's draw buffers to it through LLRenderTarget::setDrawAttachment,
which sends a fragment output to the attachment of the same index and
leaves every other attachment unread and unwritten (and a clear clears
that one alone), and gives them all back with setDrawBuffers(). It
takes the place of setDrawOutput, which nothing called: it sent one
named output into a target's first colour attachment. The pass takes
the attachment from the latched shader inputs, and draws no stars into
a target without it, which would otherwise take undefined outputs.
Under the stars the sky still draws all four, so the attachment's clear
colour stays as it is until Phase 3 stops the dome writing the
G-buffer.

llrendertarget_gl_test draws a four-output program into a four-
attachment target narrowed to each attachment in turn: only that
attachment takes its output, a clear takes it alone, and
setDrawBuffers() restores all four; with the output sent to attachment
0 instead, 12 checks fail. setDrawOutput's case goes with it.
alstarfield_gl_test now draws the stars as the pass does and checks
every other attachment is exactly as the sky left it; without the
narrowing, 12 fail.

The A/B against the old stars, as all of Phase 2 now draws them:

- albedo path: 77 to 9,062 of 14.2k to 117.3k lit pixels differ, by at
  most 1/255 to 3/255, in every scene fewer than the old stars
  themselves change when moved 1/256 of a pixel (352 to 31,376
  pixels, 1/255 to 4/255);
- emissive path: 4,924 to 35,754 pixels at night, by at most 2/255 to
  16/255 at bright overlaps, which is one R11G11B10F step where the
  stars' blends round in a new order (the old shaders in the new order
  give the same), again fewer than the moved old stars (13.4k to
  63.8k); at dusk 9.3k and 16.7k pixels differ, nearly all by under
  half a step, from the stars the brightness cull leaves out, at most
  0.75/255 where two of them overlap.

glslangValidator -l links all eight star programs; a control that
renames the emissive output fails the four emissive ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 97e9b2f4-238d-4660-b3a2-8f9119469a95
Commits

Reviewing files that changed from the base of the PR and between c005f5b and f0985ff.

Files selected for processing: 21
  • indra/alrender/CMakeLists.txt
  • indra/alrender/alstarfield.cpp
  • indra/alrender/alstarfield.h
  • indra/alrender/alstarfieldgenerate.h
  • indra/alrender/tests/alstarfield_bench.cpp
  • indra/alrender/tests/alstarfield_draws.h
  • indra/alrender/tests/alstarfield_gl_test.cpp
  • indra/alrender/tests/alstarfield_test.cpp
  • indra/llrender/llrendertarget.cpp
  • indra/llrender/llrendertarget.h
  • indra/llrender/llvertexbuffer.cpp
  • indra/llrender/llvertexbuffer.h
  • indra/llrender/tests/llrendertarget_gl_test.cpp
  • indra/newview/app_settings/shaders/class1/deferred/starsF.glsl
  • indra/newview/app_settings/shaders/class1/deferred/starsV.glsl
  • indra/newview/lldrawpoolwlsky.cpp
  • indra/newview/lldrawpoolwlsky.h
  • indra/newview/llviewershadermgr.cpp
  • indra/newview/llviewershadermgr.h
  • indra/newview/llvowlsky.cpp
  • indra/newview/llvowlsky.h
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@RyeMutt
RyeMutt added this pull request to stack #585 October 27, 2026 00:29
GCC's -Wclass-memaccess, an error under the Linux rows' warnings as
errors, refuses a memset over an LLVector4a, whose one member is private.
The test sets and compares the guard past the last vertex through
getF32ptr() instead, the same sixteen bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RyeMutt
RyeMutt merged commit e9d2331 into develop Oct 27, 2026
23 checks passed
@RyeMutt
RyeMutt deleted the rye/sky-p2-stars branch October 27, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant