diff --git a/docs/tickets.md b/docs/tickets.md index e227841..cefae79 100644 --- a/docs/tickets.md +++ b/docs/tickets.md @@ -689,3 +689,54 @@ symbolized stack; fix then. (profiler overlay/history strings are the heaviest string traffic in the crashing runs), or an engine-side heap read overrun in a small shared helper. Audit report: `shooter/docs/audit-round2.md` finding F1. + +## EN-021 — SSR + IBL specular exclusive ownership 🟡 + +**Why.** Round-2 audit (B): compose is `hdr + ssr` and `fs_main_scene` +already adds IBL specular into hdr for metals — pixels with an SSR hit +double-count specular for roughness ≈0.05–0.85, worst for metals at +r≈0.55–0.75 (in the shooter: alien carapaces / weapon per their glTF +factors). Dielectrics are largely starved of IBL spec by design and are +fine; the shooter's custom world materials never call ibl() and are +unaffected. + +**Why not a one-liner.** Scaling `ibl_spec` by the complement of the SSR +fade must be PAIRED with an env fallback on SSR miss (today miss = black, +ssgi.rs:1495) or off-screen-reflection pixels lose their specular +entirely — a worse artifact than the double-count. The fallback needs the +env cubemap bound into the SSR pass (bind-group layout change). + +**Sketch.** (1) Bind env_tex into the SSR march; on miss return +`sample_env(r, r*max_mip) * fresnel * roughness_fade * strength` instead +of black. (2) In `fs_main_scene`, scale `ibl_spec` by +`1 − (1 − smoothstep(0.5, 0.85, r)) * ssr_strength` (the exact +complement of the SSR shader's own fade). (3) Regenerate goldens; verify +with the audit's metal-ROI protocol (on-hit vs panned-off luma converge +<5%). + +**Interim calibration option:** `set_ssr_strength(0.25–0.35)` halves the +overlap engine-wide with zero shader work. + +## EN-022 — Motion vectors for material-system draws 🔴 + +**Why.** Round-2 audit (F8): every material-path draw writes velocity = 0 +(terrain/tree/grass/building — the whole static world plus wind sway), +so TAA's motion adaptation (`post.rs` motion_alpha ramp) never engages +for the world: camera translation is covered only by depth reprojection +and object sway not at all. This is the primary mechanism behind TSR 0.5 +shimmer on thin grass + sway ghosting, and it blocks any future +motion-blur quality on the world. + +**Scope.** Per-draw previous-frame model matrix (the per-draw UBO slot +already carries the current one; the shadow path already keeps a CPU +copy in `MaterialDrawCommand.model`), previous view-proj in PerView (the +core path has it), and a velocity write in the material ABI's OpaqueOut +path — plus the wind displacement evaluated at previous-frame time in +foliage vertex shaders so sway produces real motion vectors. Roughly: +ABI struct + material_system plumbing + 4 world materials + goldens. + +**Acceptance.** Strafe at the audit's S7 pose: thin-grass shimmer index +(frame-diff metric) drops materially; wind sway no longer ghosts; +`post.rs` motion clamp engages on world pixels (debug: motion_alpha +visualization). + diff --git a/native/shared/src/ffi_core/visual.rs b/native/shared/src/ffi_core/visual.rs index a2218b8..5018d8c 100644 --- a/native/shared/src/ffi_core/visual.rs +++ b/native/shared/src/ffi_core/visual.rs @@ -218,6 +218,14 @@ macro_rules! __bloom_ffi_visual { }) } + // bloom_set_sharpen_strength [round-2 audit F8] + #[no_mangle] + pub extern "C" fn bloom_set_sharpen_strength(strength: f64) { + $crate::ffi::guard("bloom_set_sharpen_strength", move || { + engine().renderer.set_sharpen_strength(strength as f32); + }) + } + // bloom_set_sun_shafts [source: macos] #[no_mangle] pub extern "C" fn bloom_set_sun_shafts(strength: f64, decay: f64, r: f64, g: f64, b: f64) { diff --git a/native/shared/src/renderer/draw2d.rs b/native/shared/src/renderer/draw2d.rs index c803936..1ce8850 100644 --- a/native/shared/src/renderer/draw2d.rs +++ b/native/shared/src/renderer/draw2d.rs @@ -10,7 +10,7 @@ impl Renderer { pub fn draw_rect(&mut self, x: f64, y: f64, w: f64, h: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let base = self.vertices_2d.len() as u32; let (x, y, w, h) = (x as f32, y as f32, w as f32, h as f32); @@ -32,7 +32,7 @@ impl Renderer { pub fn draw_line(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, thickness: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let dx = (x2 - x1) as f32; let dy = (y2 - y1) as f32; let len = (dx * dx + dy * dy).sqrt(); @@ -53,7 +53,7 @@ impl Renderer { pub fn draw_circle(&mut self, cx: f64, cy: f64, radius: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let segments = 36u32; let base = self.vertices_2d.len() as u32; let (cx, cy, radius) = (cx as f32, cy as f32, radius as f32); diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ad66ec3..bf63ccb 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -656,6 +656,16 @@ struct CachedModelDraw { // Renderer // ============================================================ +/// 0-255 sRGB channel → linear f32. The 2D pass renders into an sRGB +/// swapchain view whose hardware encode expects LINEAR shader output; +/// passing the sRGB byte value straight through double-encoded every 2D +/// color (washed-bright HUD, gamma-skewed AA edges — round-2 audit F5). +/// Alpha stays linear by definition and is NOT decoded. +pub(crate) fn srgb_u8_to_linear(c: f64) -> f32 { + let c = (c / 255.0).clamp(0.0, 1.0); + (if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }) as f32 +} + pub struct Renderer { pub device: wgpu::Device, pub queue: wgpu::Queue, @@ -7851,6 +7861,14 @@ impl Renderer { self.grain_strength = strength.max(0.0); } + /// Composite-pass unsharp mask. Default 0.8; 0 disables the 4 extra + /// HDR taps + extra tonemap entirely. Round-2 audit: this was + /// hardcoded with no runtime control while visibly haloing + /// silhouettes at 4K output (F3/F8). + pub fn set_sharpen_strength(&mut self, strength: f32) { + self.sharpen_strength = strength.max(0.0); + } + /// Sun shaft (screen-space god ray) strength. 0 (default) = off. /// 0.4 = subtle haze, 1.0+ = obvious cinematic shafts. The /// shafts are sampled from the depth buffer along a screen-space @@ -9841,14 +9859,24 @@ impl Renderer { } } + /// Raw 0-255 → 0-1, no gamma. Used by the 3D immediate batch and + /// model tints, whose values feed the linear HDR pipeline directly — + /// changing their interpretation would silently re-tint every + /// existing drawCube/drawModel call in shipped games. fn color_to_f32(r: f64, g: f64, b: f64, a: f64) -> [f32; 4] { [(r / 255.0) as f32, (g / 255.0) as f32, (b / 255.0) as f32, (a / 255.0) as f32] } + /// 2D variant: sRGB-decodes rgb so the sRGB swapchain view's hardware + /// encode does not double-encode (see `srgb_u8_to_linear`). + fn color_to_f32_srgb(r: f64, g: f64, b: f64, a: f64) -> [f32; 4] { + [srgb_u8_to_linear(r), srgb_u8_to_linear(g), srgb_u8_to_linear(b), (a / 255.0) as f32] + } + pub fn draw_triangle(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let base = self.vertices_2d.len() as u32; self.vertices_2d.push(Vertex2D { position: [x1 as f32, y1 as f32], uv: [0.0, 0.0], color }); @@ -9860,7 +9888,7 @@ impl Renderer { pub fn draw_poly(&mut self, cx: f64, cy: f64, sides: f64, radius: f64, rotation: f64, r: f64, g: f64, b: f64, a: f64) { self.ensure_draw_state(0); - let color = Self::color_to_f32(r, g, b, a); + let color = Self::color_to_f32_srgb(r, g, b, a); let n = sides as u32; if n < 3 { return; } let base = self.vertices_2d.len() as u32; @@ -9905,7 +9933,7 @@ impl Renderer { pub fn draw_texture(&mut self, bind_group_idx: u32, x: f64, y: f64, tint_r: f64, tint_g: f64, tint_b: f64, tint_a: f64) { let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); if tw == 0 { return; } - let color = Self::color_to_f32(tint_r, tint_g, tint_b, tint_a); + let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); self.draw_textured_quad(x as f32, y as f32, tw as f32, th as f32, 0.0, 0.0, 1.0, 1.0, color, bind_group_idx); } @@ -9917,7 +9945,7 @@ impl Renderer { ) { let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); if tw == 0 { return; } - let color = Self::color_to_f32(tint_r, tint_g, tint_b, tint_a); + let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); let u0 = src_x as f32 / tw as f32; let v0 = src_y as f32 / th as f32; let u1 = (src_x + src_w) as f32 / tw as f32; @@ -9934,7 +9962,7 @@ impl Renderer { ) { let (tw, th) = self.texture_sizes.get(bind_group_idx as usize).copied().unwrap_or((0, 0)); if tw == 0 { return; } - let color = Self::color_to_f32(tint_r, tint_g, tint_b, tint_a); + let color = Self::color_to_f32_srgb(tint_r, tint_g, tint_b, tint_a); let u0 = src_x as f32 / tw as f32; let v0 = src_y as f32 / th as f32; let u1 = (src_x + src_w) as f32 / tw as f32; diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 2c84307..ecb3407 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -849,18 +849,13 @@ fn fs_main_scene(in: VertexOutputScene) -> SceneOut { // (the bright rim glow when the sun is behind a tree). Gated on the // alpha-cutoff so only cut-out foliage materials get it; opaque surfaces // (cutoff == 0) are unaffected. Matches shade_foliage's transmission term. + // Round-2 audit: this block was pasted TWICE (1.7x strength) and ran + // unshadowed — a canopy in another tree's shadow still glowed at full + // transmission. De-duplicated and multiplied by the sun shadow factor. if (alpha_cutoff > 0.0) { let trans = pow(max(dot(v, -legacy_dir), 0.0), 3.0) * 0.85; - lit += base_color * lighting.light_color.rgb * lighting.light_dir.w * trans; - } - - // Foliage backlit transmission — sun bleeding THROUGH alpha-cut leaf cards - // (the bright rim glow when the sun is behind a tree). Gated on the - // alpha-cutoff so only cut-out foliage materials get it; opaque surfaces - // (cutoff == 0) are unaffected. Matches shade_foliage's transmission term. - if (alpha_cutoff > 0.0) { - let trans = pow(max(dot(v, -legacy_dir), 0.0), 3.0) * 0.85; - lit += base_color * lighting.light_color.rgb * lighting.light_dir.w * trans; + lit += base_color * lighting.light_color.rgb * lighting.light_dir.w * trans + * direct_shadow; } let dir_count = u32(lighting.dir_light_count.x); diff --git a/native/shared/src/text_renderer.rs b/native/shared/src/text_renderer.rs index 49b9f34..3affa0a 100644 --- a/native/shared/src/text_renderer.rs +++ b/native/shared/src/text_renderer.rs @@ -270,10 +270,13 @@ impl TextRenderer { None => return, }; + // sRGB-decode to linear like every other 2D vertex color — the + // swapchain view's hardware encode expects linear shader output + // (see renderer::srgb_u8_to_linear). let color = [ - (r / 255.0) as f32, - (g / 255.0) as f32, - (b / 255.0) as f32, + crate::renderer::srgb_u8_to_linear(r), + crate::renderer::srgb_u8_to_linear(g), + crate::renderer::srgb_u8_to_linear(b), (a / 255.0) as f32, ]; @@ -419,10 +422,13 @@ impl TextRenderer { None => return, }; + // sRGB-decode to linear like every other 2D vertex color — the + // swapchain view's hardware encode expects linear shader output + // (see renderer::srgb_u8_to_linear). let color = [ - (r / 255.0) as f32, - (g / 255.0) as f32, - (b / 255.0) as f32, + crate::renderer::srgb_u8_to_linear(r), + crate::renderer::srgb_u8_to_linear(g), + crate::renderer::srgb_u8_to_linear(b), (a / 255.0) as f32, ]; diff --git a/native/shared/tests/golden/shapes_2d.png b/native/shared/tests/golden/shapes_2d.png index 1bb3aee..4ae3215 100644 Binary files a/native/shared/tests/golden/shapes_2d.png and b/native/shared/tests/golden/shapes_2d.png differ diff --git a/package.json b/package.json index dfa63c0..0c8994e 100644 --- a/package.json +++ b/package.json @@ -1229,6 +1229,13 @@ ], "returns": "void" }, + { + "name": "bloom_set_sharpen_strength", + "params": [ + "f64" + ], + "returns": "void" + }, { "name": "bloom_set_sun_shafts", "params": [ diff --git a/src/core/index.ts b/src/core/index.ts index f1be5fe..b11a2b0 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -20,6 +20,7 @@ declare function bloom_set_fog(r: number, g: number, b: number, density: number, declare function bloom_set_chromatic_aberration(strength: number): void; declare function bloom_set_vignette(strength: number, softness: number): void; declare function bloom_set_film_grain(strength: number): void; +declare function bloom_set_sharpen_strength(strength: number): void; declare function bloom_set_sun_shafts(strength: number, decay: number, r: number, g: number, b: number): void; declare function bloom_set_auto_exposure(on: number): void; declare function bloom_set_taa_enabled(on: number): void; @@ -265,6 +266,15 @@ export function setFilmGrain(strength: number): void { bloom_set_film_grain(strength); } +/** + * Composite unsharp-mask strength. Engine default 0.8; 0 disables the + * sharpen taps entirely. At high output resolutions the default visibly + * halos high-contrast silhouettes — tune per game. + */ +export function setSharpenStrength(strength: number): void { + bloom_set_sharpen_strength(strength); +} + /** Screen-space sun shafts (god rays). strength 0 = off. */ export function setSunShafts(strength: number, decay: number, r: number, g: number, b: number): void { bloom_set_sun_shafts(strength, decay, r, g, b);