33fc0c20239ec6bb1166f31c8fd156fb6aa8f27b
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
33fc0c2023 |
Froxel volumetrics: the haze stops painting shadowed air the colour of sunlit air
`volumetricFog` gains tier 3, "Volume", with a "Shadowed Air" dial beside it; the Ultra rung of Lighting Quality takes it. A view-space 160x90x64 froxel grid — a fixed BUDGET, so every resolution costs the same — marched one compute thread per screen COLUMN, front to back, with a reprojected eight-frame history. Inject and integrate in one pass, because a froxel past the last shadow cascade has no evidence either way and the honest answer is the mean of what the rest of its column could see; a thread that owns the column can carry that and a froxel-per-invocation dispatch cannot. It publishes two things: - **the lit share** — how much of the air in front of each pixel the sun actually reaches, weighted by each slice's own contribution to what the eye sees through it. `volumetric_fog`'s haze multiplies its colour by it. The haze below this rung is the reference's: a zone colour mixed in by distance, the SAME colour whether the air it tints stands in open sun or a mountain's shadow, and no screen pass can tell it otherwise because a shaft march only ever ADDS light. On an Elwynn overlook at noon, 74% of the frame is air the sun does not fully reach. - **the lamps' in-scatter**, free once a volume exists: the phase lobe evaluated at every point of the air rather than once per ray at its closest approach, attenuated by the medium it crosses and strengthened when the medium is. `lampFog`'s analytic halos retire into this solve at tier 3, and that row still chooses which lamps and how many. The gain is derived, `0.9 / density(clear night)`, so the two lanes match on a clear night. At the Goldshire inn, isolating each by rendering lampFog 0 against 2 on both (the bases are byte-identical, which is what makes it a comparison): the volume's are 1.30x and wider. The sun's SHAFTS deliberately stay on `volumetric_light`'s per-pixel march, and that is a measurement. A volume bands in hard horizontal steps at 160x90, 320x180 and 534x300 alike, because one froxel column hands the same value to about a hundred pixels and a canopy's shadow is finer than any grid worth paying for. Spreading each column's samples across its own screen footprint (R2 down the column) turns the steps into a gradient and is worth knowing for any volume, but the march still wins on detail. docs/GRAPHICS.md has the sweep. Safety is one number: fully lit air multiplies by EXACTLY 1, so at Shadowed Air 0 the lane renders byte-identical to High over all 1,440,000 pixels with every pass still running. Getting that exact needed `1 - (1-floor)*(1-lit)` and not the `mix(floor, 1, lit)` it equals — `mix` is a fused multiply-add and lands a last ulp below 1.0, which moved five of nine tier 0-2 control pairs with the rung switched OFF. The shipped floor, 0.85, is deliberately half the depth of the first one tried, and the dial reaches twice it. Control: nine tier 0-2 pairs against the previous build, five byte-identical and four differing by one or two PIXELS out of 1,440,000 at 1-2 of 255 — every tier-0 pair identical, and tier 0 is the tier that switches the fog pass off, so the remainder is the fog shader's instruction schedule changing because the shader did. Cost is under the noise of both instruments: the frame probe puts Volume inside High's own run-to-run spread, and the GPU pass timer puts `gpu_other` at 0.75 -> 0.76 ms. Also recorded: `water-lake` leaves the deterministic row once shadows are forced on — it differs from a build of ITSELF by up to 88/255 on 0.03% of pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
848c840ecf |
SSILVB: the AO lane stops only darkening and starts bouncing light
`ambientOcclusion` 3 (Indirect Light), the Ultra rung. Tier 3 of the AO row rather than a row of its own, because it is one trace: the bitmask that measures occlusion properly is what produces the bounce, so a player choosing between them would be choosing between two halves of the same thing. Classic SSAO sums a cosine term per tap, so two occluders covering the same direction count twice and a cluttered corner over-darkens. A bitmask counts DIRECTIONS: each slice owns a 32-bit word of angular sectors, a tap sets the run it spans, overlap is a no-op. And the bits a tap sets that were NOT already set are exactly the share of the hemisphere it is the nearest thing in — i.e. the fraction of the receiver's light it alone is responsible for. Its own pixel colour, weighted by that, IS the one-bounce indirect term. No second trace, no probes, no extra pass. ## The lane was already shaped for it - `STAGE_AO` already bound the resolved scene colour, for the bright-pixel protection mask. That buffer is exactly what a screen-space gather reads. - The apply needed no new pass and no dual-source blending: visibility in `src.a`, bounce in `src.rgb`, blend `One, SrcAlpha` = `bounce + dst*vis`. - Only the half-res stages grew, by one attachment each, and the uniform's two spare floats carried the strength and thickness, so its layout never moved. ## The one part that is ours and not the paper's **The bounce is scaled BY THE OCCLUSION.** A physically-measured bounce is wrong here: 1.12's lighting already carries an ambient term standing in for indirect light, so adding a measured one double-counts it. Unscaled it lifted a daylight exterior's median from 85.7 to 101.3 and washed the frame flat. Scaled by `occ`, the lane puts back only the share of the ambient the occlusion just took away, in the occluder's colour. An open field gets nothing, because nothing was removed there; a corner gets the colour of the wall shading it. Redridge overlook noon median 85.7 -> 86.7 Goldshire inn noon median 26.7 -> 29.0 Neutral in daylight, +8.6% in the shaded room — the same shape as the auto-exposure lane's floor of 1.0, for the same reason. The gather runs in LINEAR (`c*c` in, `sqrt` out): the scene is stored in gamma, and 0.5 gamma is 0.22 of the light, not half of it. ## Cost Frame time is unchanged (inn 2.22 -> 2.21 ms over 600 frames; overlook 2.25 -> 2.11). That is the frame being CPU-bound, not the lane being free. The GPU meter is the honest instrument: whole-frame GPU 1.68 -> 1.81 ms, and the bucket these passes land in 0.40 -> 0.54. So **+0.14 ms of GPU over High and nothing on the main thread** — which is why the Options text says "a fraction of a millisecond" rather than what I first wrote before reading the meter. ## Two bugs, both found in the intermediate rather than the frame - **Screen +y is view -y.** `view_at` flips clip y, so a slice direction built as `vec3(omega, 0.0)` measures its taps against a mirrored normal. One slice of two was right and the other inverted, averaging to A FLAT FLOOR READING 50% OCCLUDED. `WOW_AO_DEBUG=4` showed it in one run; the composited frame never would have. - **`a / b` and `a * (1.0 / b)` are not the same number.** Factoring the blur's divisor into one reciprocal — a tidy-up, not a change — moved `water-lake` at tier **1** by up to 53/255 on 0.021% of its pixels, because the AO is multiplied into the colour the water lane's depth-march then refines by bisection, and one ulp picks a different step. Restored to two divides. ## Control Tiers 0-2 byte-identical to the build before this, on three deterministic scenarios at three tiers each, nine of nine. `fence-shadowline-night` had to be dropped mid-control, and it sharpens the determinism table added last commit: it screens as deterministic at the DEFAULT tier and is not at tier 1. Screen a scenario at the setting you are about to measure it at, not at its default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
4c3c128594 |
Screen the capture scenarios for determinism, and correct what rested on one
Routine work on the sharpening lane needed a byte-identity control, so I checked the baseline against itself first. Three of seven scenarios do not reproduce run to run with ONE binary: overlook-noon, water-lake, water-ocean, fence-shadowline-night identical stormwind-canal-near bimodal: 7 of 15 pairs identical, rest 0.01% / max 96 inn-noon, volfog-inn usually the bottom bit; one pair 37.8% / max 199 `stormwind-canal-near` is the one that matters, because the water-mirror write-up used it as a byte-identity control and it cannot be one. The identity measured there was a coin flip that landed well. The finding it supported still stands, but on its EXTENT and not on the amplitude it was also quoted at. "0.75% of pixels by up to 96/255" reads as one observation; it is two, and only the first is evidence. 96 is what the canal's own flicker reaches with nothing changed at all — the amplitude says nothing. 0.75% against the scene's own 0.01% is 75x, which is the part that was real, and the run condition is still the right fix. docs/GRAPHICS.md's harness section now carries the table, so the next person reaching for a control picks from the first row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
11dda4f9cf |
Two cheap lanes: RCAS sharpening, and a field of view a player can move
Both were named in docs/GRAPHICS.md as nearly free and neither had been built. They are unrelated to each other; they land together because each is small. ## Sharpening (`sharpening`, 0 Off / 1 Low / 2 Medium / 3 High) RCAS, FSR 1.0's sharpening half taken on its own. It does not resample, so it needs none of the upscaler's motion vectors, jitter or history — which is the whole reason it can land while §7 cannot. One 5-tap cross; the lobe is not a constant but the widest one that cannot push the result outside the ring's own range, solved per channel against a peak of 1.0 and taken as the most conservative of the three. Flat regions get nothing and nothing rings. The seat is most of the design. `Core3d`, after `FfxGlowLabel` and before `Node3d::Bloom`: - After the glow, not before. `FfxGlowNode` builds the blur chain the glow and the underwater haze read, and a sharpened bloom source is how an unsharp mask turns a lit window into a ringed halo. - The world view only. The world's last reader is the FFXGlow combine drawn inside the UI camera's main pass, which binds `main_texture_view()` at node-run time per frame, precisely so a `post_process_write` upstream lands. The interface is drawn afterwards into a different target — and sharpening text is the one thing this must never do. - Every tap saturates, as the combine's own scene read does one pass later: the limiter is solved against a peak of 1.0, so a super-white tap would invert the bright side's sign. Nothing is lost, the blur chain already took its copy. - No denoise term. The deband dither it would exist to suppress is added downstream, in the combine's exit. Measured, 600 frames: Redridge overlook 2.95 -> 3.02 (Medium) -> 3.01 (High); Goldshire inn 3.46 -> 3.51 -> 3.29. Inside the noise, the inn reading *faster* at High being how you know. CPU flat. What it buys: 3.8% / 19.9% / 48.6% of the frame moved by more than 2/255. **The first run hit the trap this repo already has a note about.** `max(4.0 * mx, 1e-5)` where `mx` is a `vec3` is `error: inconsistent type passed as argument #2 to max`. naga refused the shader, the pass drew nothing, and the capture came out looking perfectly normal. One `grep -ciE 'error:'` on the capture log answered it before a single image was diffed. ## Field of view (`fieldOfView`, vertical degrees, 40..70, default 45) Field of View on the Graphics page under Render Scale, live as you drag. It is the BASE lens, not a fourth factor. `player::scoped_view` already multiplied two things in and neither is a field of view: SuperWoW's `FoV` is the reference's `[camera+0x40]` as a ratio, the spyglass is a zoom off normal. Both answer "how much wider than normal"; nothing a player could move answered what normal IS, and the answer was a `const` in `benilla-world`. So the term order is `base × FoV × zoom`, and an addon writing `FoV` still means the same fraction of the lens the player chose. Vertical degrees, not horizontal, which keeps the reference's Hor+ framing: a fixed vertical lens widens horizontally as the window does, so an ultrawide player gains picture at the sides instead of losing it off the top. The default is `CAM_FOVY` to the bit — `45f32.to_radians()` and `FRAC_PI_4` are both `0x3f490fdb` — which the defaults-mirror test now asserts on `to_bits()` rather than against a tolerance. An ulp of projection is a pixel in a capture diff, and a row that reframes the world for a player who never opened Options is not a row, it is a regression. ## Both lanes off: byte-identical Against a `git stash`ed build of the same tree: `overlook-noon` and `water-lake` identical, and `fieldOfView=45` written explicitly identical to the row never touched (40 and 65 move 97.6% and 99.3% of the frame). `inn-noon` is excluded WITH PROOF rather than quietly: two runs of the *same* binary differ on 37.8% of their pixels by up to 199, because that scene's flame emitters do not reproduce. Its own before/after delta is 0.00% of pixels at a maximum of 1. A scenario that cannot reproduce itself cannot be a control, and the number to check first is always the baseline against itself. ## Seats Six Options edits each, per the recipe, including the silent one (`OPTIONS_PAGE_ROWS`). Sharpening is a dropdown on Advanced Graphics with the other screen effects and reuses the existing `qOff/qLow/qMedium/qHigh` labels; Field of View is a `raw`-format slider on Graphics, whose label is literal because that page is not localised. Preset ladder: `sharpening` takes `ambientOcclusion`'s shape (`0 0 1 2 2`) because a look change starts at Medium in this file's convention; `fieldOfView` is on no rung at all, because it changes how the game feels to play and that is the player's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
280c2e886f |
Planar water: the mirror works, and ladders in at waterQuality 3 (Ultra)
The starburst was three defects, and none of them was the oblique projection I blamed it on in the last commit. What found them was looking at the mirror's own render target instead of reasoning about the composited frame — the water reads it through a fresnel weight, a rippled UV, a plane-match band and an SSR march that paints over it, so from the outside a broken mirror and a broken water shader look identical. WOW_MIRROR_DUMP=1 now writes that target beside the capture. (Bevy's own save_to_disk cannot: try_into_dynamic knows four 8-bit formats and answers UnsupportedFormat for Rgba16Float, logging and writing nothing.) - The oblique row's scale. Replacing the near plane with the water plane costs the FAR plane: `z >= 0` stops meaning "nearer than CAM_FAR" and becomes `depth >= a*d`, a plane through the eye tilted by a. Lengyel's own scale puts that plane exactly through the frustum's far corner, and the sky dome is pinned just inside the far plane, so it straddled the boundary and was sliced along its own tessellation. Scale by the supremum of d/depth over the frustum instead, with 2% of margin. - The water surface lay ON its own clip plane. This was the starburst and it was fatal rather than untidy: depth now means "distance above the plane", so a fragment at d=0 reads z/w=1, the nearest value reverse-Z has, and beats every other surface in the frame. The mesh is wave-displaced, so its triangles straddled the plane instead of lying in it, and each was clipped to a sliver that kept the winning depth. SURFACE_CLEARANCE lifts the plane a yard: the swell sums to 0.34 and the probed height answers for the surface under the eye, not this fragment. - The mirror was stored upside down. A true reflection is what a camera on the far side sees with its up vector reflected too — an improper basis no Transform can hold. The honest right-handed camera sees the same surfaces through the same frustum and stores the image flipped, so the shader samples 1 - v; that costs no winding flip, so backface culling stays correct. It read as a lake reflecting the ridge the RIGHT way up. Measured: Lake Everstill 2.87 -> 3.36 ms mean over 600 frames (+17%), the Stormwind canal inside the noise, CPU +0.2 to +1.1 ms. It is the first lane in docs/GRAPHICS.md that spends main thread, so it is a rung of its own above High and the default stays 2. Seated as a setting, per the standing rule that no graphics lane hides behind an env var: waterQuality gains tier 3, the Ultra preset takes it, and the Advanced Graphics dropdown gains a fourth entry in both locales. With the lane off, both scenarios are byte-identical to the build before it — which took two findings of its own: - The system could not live in Update at all. `&mut Transform` on a camera is shared with everything that moves anything; declaring it put 59 undeclared orders into Update and tripped the schedule ratchet, which is what kept this behind an env var. It belongs in PostUpdate with the skybox and the billboards, after propagation and before visibility, where it also reads the eye's settled pose instead of a frame-old one. (The comment blaming ResMut<Assets<Image>> for those 59 was wrong — dropping the asset handle changed the count by zero.) - A system that runs and returns on its first line is still not free. Holding &mut Camera and &mut GlobalTransform for a frame is enough to change which undeclared orders the executor picks: the canal moved 0.75% of its pixels by up to 96/255 with the lane switched off. So the whole system sits behind a run condition rather than an early return — and the same effect killed a .before(UpdateDirectionalLightCascades) order that was correct in itself. PostUpdate's ambiguity ceiling goes 466 -> 495 with the reason (the pairs are structurally impossible: nothing else in the schedule names the mirror camera); Update's ratchets down 4973 -> 4952. Also restores scripts/gates.sh's `where` example, which had gone missing. Its absence was silent — cargo's error went to /dev/null and `|| true` made it read as "no install" — so the memo key and the player-tests gate saw none even when WOW_DATA was set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
355bac7b0e |
Planar water: the mirror pass, built and inert behind a dev lever
A second camera on the far side of the water plane, rendering into a half-viewport target that `enhanced_water.wgsl` reads BENEATH the screen-space march — so SSR still wins wherever it finds something, and the mirror only fills the hole SSR cannot reach: the ray leaving the screen, and the top-edge fade, which is exactly where a shallow-angle lake wants its far bank. **It does not work yet, and it is not offered to anyone.** The mirrored eye sits as far below the surface as the real eye sits above it — 26 yd down on Lake Everstill — which is inside the lake bed, so the pass must clip at the water plane or it renders sand. The clip is implemented (Lengyel, adapted to this client's reverse-Z, around a finite far plane because the oblique row makes depth affine and an infinite far collapses every distant fragment onto one value) and it is WRONG: the mirror comes back a radial smear. So this is `WOW_WATER_MIRROR=1`, a dev lever — no CVar, no row in Options. It graduates to both, like every other lane, once a capture shows a reflection instead of a starburst. What IS established, and is why this lands rather than waiting: - **With the lane off the water is byte-identical** to before the pass existed, verified against a `git stash`ed build of the same tree. - With it on it reaches the water and moves 63% of a lake frame — the plumbing, the binding, the uniform and the composite all carry. - Off, the plugin **registers nothing at all**, the posture `static_gx::enabled` takes. That is not tidiness: `drive_mirror` declares `ResMut<Assets<Image>>`, and merely registering it dormant added 59 undeclared orders to `Update` and tripped the schedule ratchet. Three things this cost, all of them now written into `docs/GRAPHICS.md` and all of them my own: 1. **The pipeline failed to compile for most of a session while every A/B read a clean zero.** The field is `clip_position`; `WaterFragment` has no `position`. Naga refused the shader, the water fell back to another path, and I measured the fallback over and over — including twice mistaking the lake bed seen through clear water for a working mirror. The client logs pipeline errors rather than panicking, by design, so the answer was in the log the whole time. **Grep the capture log for `error:` before believing any shader A/B.** 2. **A disabled lane is not automatically free.** Starting SSR's march from the fogged `reflection` instead of `sky` looked equivalent and moved 10.1% of a lake frame with the mirror switched off. The control that catches that is diffing lane-off against a stashed build — not against an older capture, which is what I reached for first. 3. **A flag colour has to be one the scene cannot produce.** My first three "every gate passes" measurements were counting the orange lake bed as my red flag, which sent the hunt in the wrong direction for an hour. The pass's own cost is deliberately unmeasured: it draws the scene a second time, so it is the first item on the programme that spends main thread, and pricing a broken render against item 1's view-distance table would mean nothing. That happens when the projection does. Verified: ALL GATES GREEN (591 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
77fce9210e |
The water already reflected the scenery; it was switched off
`docs/GRAPHICS.md` said we had refraction and foam but no mirror. That was
wrong. `waterQuality` 2 has carried a depth-marched screen-space
reflection of the scenery all along (`enhanced_water.wgsl`, "HIGH"), with
bisection refinement against the real scene depth rather than the fixed
probe its WarcraftXL source took. What was missing was the switch: the
registered default was Enhanced (sky only), and of the five lighting rungs
only Ultra reached High.
The line holding it back said exactly what it was waiting for —
"Mirror reflections stay opt-in until their cost is measured." So I
measured it, `WOW_FPS_PROBE=300`:
Classic Enhanced High
Lake Everstill 1.91 ms 1.84 ms 1.90 ms
open ocean 1.89 ms 1.88 ms 1.89 ms
Every tier is inside the run-to-run noise — Enhanced lands FASTER than
Classic on the lake, which is how you know you are reading noise rather
than a cost. What it buys is not noise: the far bank and the mountains
appear in the water, 18.5% of the lake frame and 35.1% of the ocean frame.
So High becomes the default and the High rung. That is a weld across four
places the tests hold together, and each one bit in turn and was fixed as
it asked: `REGISTERED`, `VideoConfig`'s literal, `WaterQuality::default`,
and the rung itself — the High rung IS the registered defaults, which
`the_high_preset_is_the_registered_defaults` states and enforces.
Low and Medium keep Enhanced deliberately, and that needed saying in the
one test that assumed every rung between Off and Ultra sits AT its
default: free on the machine it was measured on is an argument for the
default, not for the rung whose whole job is hardware that cannot afford
what this one can. `water_and_lava_observers_clamp_and_water_high_is_opt_in`
asserted the old decision in its name and its body, so it is renamed and
now pins the pairing the other way: High reflects, Medium does not.
The honest remaining gap is written into the doc rather than claimed as
done: SSR gives up when the ray leaves the screen and fades toward the top
edge, which is where a shallow-angle lake wants its far bank. A planar
pass would fill that — and it is the first item on the list that draws the
scene twice, so it costs main thread and gets priced against item 1's
view-distance table before anyone builds it.
Verified: ALL GATES GREEN (492 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
88b6998453 |
Auto exposure: dark rooms read at their own level
The programme's item 1 was terrain horizon occlusion, ranked first on the reasoning that it would make the frame cheaper on the thread that is actually full. **I measured it before building it, and the premise does not hold for this renderer**, so this is item 2 instead. The numbers are in `docs/GRAPHICS.md`: cutting the view distance from 777 yd to 177 — removing four fifths of the visible world — saves 0.44 ms of a 3.6 ms frame, p95 is flat at 6.4 ms at every distance, no main-world system in a steady vista exceeds 0.12 ms/run, and the model cull already takes 6,016 submeshes to 29 drawn. Horizon occlusion can only remove the fraction of that which sits behind a hill. The reference needed it on a 2004 CPU with no GPU-side culling; we do not. What the same harness said about item 2 was the opposite. Before anything was written: the Elwynn vista renders at a gamma median of 87.6/255 with 1.4% of its pixels under 32, the Goldshire inn at noon at 31.9 with 50.1% under 32, and the inn's kitchen at **12.8 with 94.5% under 32**. That is not a dim room, it is a black screen — and the reference has the same problem, because it has no metering either. A histogram-and-reduce over the HDR view target, smoothed in the log domain, applied ahead of the shafts, bloom and glow chain so FFXGlow's blur — which is built from the scene — sees the same exposed image everything else does. Two departures from the textbook version, both forced by what this renderer's pixels mean (the world is drawn in the reference's gamma convention, which the combine clamps to 1.0): - The gain has a **floor of 1.0**: it only ever brightens. Aiming at the exterior's own measured level is what lands it at 1.0 outdoors with no special case — the vista's median moves 87.6 -> 87.5, and 0.12% of its pixels with it. Daylight is the one look this client is measured against and nothing here was worth moving it. - It has a **ceiling**, which is the tier. The kitchen meters to roughly 24x; uncapped it comes out as daylight, which erases the room instead of making it readable. Subtle and Full are 2x and 4x: "a dark room you can see in" and "one you can read in", neither "a lit room". Measured after: inn noon goes 50.1% -> 4.6% of pixels under 32, the kitchen 94.5% -> 40.3%, and the vista does not move. **+0.08 ms of GPU**, nothing on the main thread. The calibration is the one thing to get right if this is ever re-derived, and it is written down because I got it wrong first: the aim level is the level of the scene THIS PASS SEES, not of the screenshot. The glow adds `w·blur²` downstream, so the finished vista log-averages to 0.0931 linear and the pass's own input to 0.060. Calibrating against the former aimed the lane 1.5x high and brightened the daylight exterior it exists to leave alone — which the vista capture caught immediately. Two rows on Advanced Graphics, both locales, the blend greyed while there is no gain to blend; on the Graphics Preset ladder from Medium, in the shape the other post lanes take. Classic keeps it off, as the reference has no metering at all. Verified: ALL GATES GREEN (550 s); `benilla-app --lib` against the install at the exact 269-failure baseline, set-identical by `comm`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn |
||
|
|
c315fb4f35 |
Anisotropic filtering and trilinear apply in the session
The Options row shipped last commit said the setting needed a relaunch.
It does not have to. The reference's "enabled upon restart" is about its
TEXTURES, not its CVars — both register with `flags = 1`, live — because
its sampler state was baked inside each `TextureCreate` (`0x449ae0`) and
it kept no list of live textures. Ours can be re-stamped in place.
Two things make it possible, and neither is new work:
- A governed sampler now wears `POLICY_SAMPLER_LABEL`, written by the one
constructor `TexFilterSetting::descriptor`. That is the `applyGlobal`
split the reference draws, made legible at runtime: terrain, WMO/M2 and
the liquid sheets take the policy; weather sprites, the minimap, glue,
Lua widgets, the sky and decals keep their own mode. All four governed
lanes went through that constructor, so a lane cannot forget the label
and silently opt itself out of every later change.
- Extracting a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves
the asset behind (`take_gpu_data` is an `Option::take` on `image.data`).
So every resident texture's own descriptor is still readable in the main
world, for nothing — there is no ledger to keep and none to go stale.
The policy itself stops being a `OnceLock` and becomes one atomic word
plus a revision, so it can be published again on every write and both
halves of the refresh can key off the same number in the same frame:
`refresh_policy_samplers` (render world, `ExtractSchedule`, before every
Prepare) swaps `GpuImage::sampler`, and `invalidate_materials::<M>` raises
`AssetEvent::Modified` so bind groups that captured the OLD sampler are
built again. `static_gx` carries two samplers of its own and rebuilds its
cached cell bind groups in place rather than dropping them — clearing the
cache would be one line, but a region with no GPU state does not draw, so
every building in view would blink out and trickle back under the bake
budget.
Measured, not asserted. `WOW_CVAR_LATE=<name>=<value>` is new here: it
writes CVars *after* the scene has settled, then re-settles the image
around them. A latched lever and a live one give the same boot picture, so
no boot-time A/B can tell them apart. On the Elwynn vista:
anisotropic 16 at boot vs 16 written late:
0 of 1,440,000 pixels differ
and the control that proves the harness is not doing the work (boot 16,
written 16 again late, so no refresh runs but the re-settle is still paid)
is byte-identical to a plain boot. The cost of the change is 873 samplers,
75 material bind groups and 264 static_gx cell bind groups, once; nothing
is re-uploaded, since the pixels never move.
The first run of that A/B was NOT identical — 6.7% of the frame, a dapple
of stale tree shadow on a road that was otherwise correct. That is
`CutoutShadowCasterMaterial`, which deliberately reuses the forward pass's
own leaf handle and so holds a governed sampler, and which lives in
`benilla-app` rather than the engine. Hence the shape the invalidation
took: a public `invalidate_materials::<M>` each crate registers beside its
own `MaterialPlugin`, published through the world-API wall with the record
that says why (the engine naming every material type in the program is the
inversion that wall exists to stop). Forgetting one is silent in exactly
that way — a bind group built after the change is right and one already
cached is wrong — so the capture A/B is written into `docs/GRAPHICS.md` as
the check for this feature, not a unit test pretending to be one.
The Options row keeps its deferral to Apply, now for the real reason: the
commit is cheap once and silly sixteen times, which is what a drag across
the groove would ask for.
Verified: ALL GATES GREEN (520 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|
||
|
|
b6f3bb001c |
Anisotropic filtering joins the Graphics Preset ladder from High
The reference ships `anisotropic` at 1, which is *off*, so ground texture at a
grazing angle smears to mush a few yards out — which is most of the screen in
an outdoor scene. The row was registered and wired (`benilla_assets::
TexFilterSetting`, the reference's own `[1, 16]` clamp at `0x689110`) but sat
outside the preset ladder, so no rung ever spent it and even Ultra left it off.
Measured on an Elwynn vista (`WOW_CAPTURE=vista`, the offline harness
`docs/GRAPHICS.md` now documents), one knob changed, 1600x900:
* **34.1% of the frame moves** (490,600 of 1,440,000 pixels by more than 2),
mean |d| 4.21, max 212. By band it peaks at **66.5%** across y 420-479 and
runs 48-66% from y 300 to y 780 — exactly where the ground recedes to the
horizon, which is what anisotropy is for. For scale, the interior
prop-probe table's whole stake is 7.6% of pixels by at most 14.
* **+1.0 ms of GPU and nothing on the main thread** (gpu 0.8 -> 1.8 ms, main
unchanged at 4.2-8.4 ms). The filter policy confirms it lands: "mode 4
(trilinear, aniso off)" -> "mode 5 (trilinear + aniso) anisotropic=16".
That ms is one this client is not spending. It is main-thread bound with the
GPU about two thirds idle — flat ~3 ms in play at every framerate, 0.8 ms in
this capture — so a sampler state is the cheapest picture on the table.
Ladder: Classic and Low keep the reference's 1, Medium takes 4, High and Ultra
16. Classic is the reference client, so it is judged against the registered
default the way `farclip` already is, not against zero — `anisotropic` is not a
0/1 lane, its "off" *is* 1. The High column differs from the reference, so it
is declared in `SEEDED_DEVIATIONS` with its reason, and
`graphics_rows_pass_their_observers_unclamped` now drives all five rungs
through the real setter and reads the level back off the sampler policy.
The sampler is baked into each texture at load, so a rung change lands on the
next launch, as the reference's own options window says ("enabled upon
restart"). A player already on a Custom preset is not seeded and keeps their
saved value; `/console anisotropic 16` and a restart is the whole fix there.
`docs/GRAPHICS.md` is new: where the frame's budget actually is, the offline
capture-and-diff harness every claim here uses, what is already shipped and
unspent, and the ordered list of what the picture is missing — costed on this
engine, with upscaling placed where the measurement puts it rather than where
it is usually asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
|