From 61c8f9222c7d20456056448e3853e27f3de9d908 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sat, 4 Jul 2026 10:01:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(render):=20round-2=20visual=20correctness?= =?UTF-8?q?=20=E2=80=94=20cutout=20transmission,=202D=20gamma,=20sharpen?= =?UTF-8?q?=20knob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fs_main_scene's foliage backlit-transmission block was pasted TWICE (verbatim, 1.7x intended strength) and ran unshadowed — a canopy in another tree's shadow still glowed at full strength. Dormant in the shooter today (no MASK assets drawn) but a landmine for any cutout foliage. De-duplicated + multiplied by the sun shadow factor. (audit F3 side-finding) - 2D colors (shapes, textures, text) are now sRGB-decoded to linear before hitting the sRGB swapchain view — they were double-encoded: washed-bright HUD midtones, gamma-skewed glyph AA. Deliberately scoped to the 2D pass via a separate color_to_f32_srgb helper: the 3D immediate batch and model tints keep their historical raw /255 interpretation, because flipping those would silently re-tint every existing drawCube/drawModel call in shipped games. shapes_2d golden regenerated (midtones darken to their authored values); all 3D goldens unchanged. (audit F5) - New FFI `setSharpenStrength` / bloom_set_sharpen_strength: the composite unsharp mask was hardcoded 0.8 with no runtime control while visibly haloing silhouettes at 4K output. Default unchanged. (audit F8; manifest entry included — Perry silently no-ops undeclared natives) - docs/tickets.md: EN-021 (SSR+IBL exclusive ownership — deferred because a correct fix needs the env-miss fallback in the SSR pass, not just the ibl_spec complement) and EN-022 (motion vectors for material-system draws — the in-motion quality project). --- docs/tickets.md | 51 +++++++++++++++++++++ native/shared/src/ffi_core/visual.rs | 8 ++++ native/shared/src/renderer/draw2d.rs | 6 +-- native/shared/src/renderer/mod.rs | 38 +++++++++++++-- native/shared/src/renderer/shaders/core.rs | 15 ++---- native/shared/src/text_renderer.rs | 18 +++++--- native/shared/tests/golden/shapes_2d.png | Bin 3250 -> 3346 bytes package.json | 7 +++ src/core/index.ts | 10 ++++ 9 files changed, 129 insertions(+), 24 deletions(-) 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 1bb3aeed3836d7eb58f7cd754399d03bf74de6e7..4ae32158da478ec5642f5837e1b97b649e4c1ee9 100644 GIT binary patch literal 3346 zcmeHKaZr~Ggu;#gs2wIe^Mfm;!mkWJ_e0;^?`he|si-hEo@(e>}MI<1mUwI;^{6R$V582le?KbhNI+_5Q2 zJ0DbTG#zn4-2digNuYqvRqOo?FZ8H2!{)in@M-@QbHQK~W?x>v(&W?WZ&V7w(toC0 zY_P6jbp>CmoYkcUPhUM`9U@Esbmmus!kyM#dHsl~us|`bfz2anT zU=leUE}A?|lhq>JW)VCYQL6~mh^PX$q7Z{jFQ8}AJaqdJ-FSxCB)YYTZqev=Ke^pY zZpxM9pkvQg3Z?Xa;WD~G z7b?r+4sh#mG~xUqixZm#oFKP>SIBK8xtU7$iF32CgZPpP#CP#|U3o=zSSEu^VsQ9s z7W>n`?bSY&ul0A%O;dHDe(3uz9FqYd>F)`m0aU ziUvb>n=36V-=9up0l`Hfc=oU;ZwxX#D1&MY##9=Q=>f5QciJkALY3_{%n+3v8=C>l za$&!>W-;e?o)2F7 zh=Elz+8nu%jH;M?8k7Yi;6rg`X}q|tG#2|<D)$6w$6aU>P->J3{M3P;tbzeG+mEN3VH=_6ldG70Z1hU{pC&AQB8vnHcF9G{cy{SS@jwhaQCCe-=X; zeJ!Lvo0j^mgK5XxFpZ*Q@HP25Fhc*jxWpBOCZDpmHzVJ#iS|1B^>4ZPABfN>=)?TQ zZUp2I(9NkZtx9_McPy_boVNyb!acYXD4|W|cpNgRliy2yrhwl5BqqrON>RWhX~o$6 zW{i#arE(#O;xS`9UdQ{Caw<%@5bRXtgs_4Tssactgs>|#S5OXN;cX+8P#)}XnCBT( zX>P|Y=-7mgyb(_gCW<++iTq)ed1g_;na=PBB2`gJWA^t3Aj{{|U87+cXv z>JADD#Vm$0g(1$cYJxcrE+&?)kyRoy-taR{ zt~}-$Sr$$>#1c(A)%(b}v;GBde`XCD)s44h+~JvZwGo=5p_ZX-Q!79wpMjzlV=`ck zT9@wx`F5{zv!n#`?9n27US1bW5*OyPg#+TkhUeJANOn^RyZY+CL{FH`xKvxX|P&Liny8QyU7i;2eiWcJ{F7~E*mhpS-J z)=cr;H1@8Hz2n~$8@NJ&(wK8;t8F@#0bD9G{1lRMb~w{jZ)n~y7F$<^#rZI1kPf%! zfgKEpdzyjs2gEDQj3?&fPBJkZjm<;j)cM-cDAaJnvy8(9k0|hSW?5ixKMuC#ivz>O z)C&d(V+vzXQBA9R_*h1dN1FTuCbp;~mePcbc9+9WB-AXn9MF2AJ|Y=gT;27H+fQm+osCpR!S;@Xfg6nQMbq&fP<0e|=ilN#c) zglcwBsI>uZ&k}Y8g2%IuRVbqZ!RL6mQMC}W9m~}yEZF~=#i%O e?|pF#VZm#hFH1Jw^ELjjBT=P2KHrp@TmBc%3!1?I literal 3250 zcmeHKZA_C_6mGR{6~!^oxoH-_k5#L)%WyUflUJai)yRjoVK@+@jzp(n)-Vv0T~gze zG3RGNg;@ic);1J}n-jC;#mH1pLb9yJ&1@w|F{x7!#-#ZAo}F`BoKAo2#}cz-iOKc8 z_nz}S&-uEgEF*1Uc-W*cg+dW-Tx3|LP$==gN`?A4TyiXqe1&4{7Na52RII$+H1g=Q z?1~F_q3F)8OIMCtmaK7aynZ_LZ{4RyDi+rtc{6Uxoxq3RtS>pXBjJ5d{fKU-%RK}; zJNWX+vt3%*$!)vk4elZPnp-ZR3Nr57ub5%fY4IS8P-W#7dLZlZ#pFKg_pZ5L-sl&a zWKF-f^x5+NAN>3@@T}hn6V!aE|F0~LoI;OyQPynsz>3G~EVNUPo4>>)C;OUD8GJBHbr3Fp zw62Yv&>!055tpXT-{FudFO}Vluz@aOm8=^=Z*{+}yPhByZBh$4otBa#WB3c`waeh!+U0mUi z>JLP%X8Vx@ zLlT4H49%SKR#+FmDcdCLlS%{L&dRHj{rA`6WL?YXnm$;ZhMG1ajGo9HCz#tDMm)e_ z##kYoP_|&G*eNcyAjQ{EJ?OP|u(JpQ7j$(2ZxCi{X5(y)tdAMA|Ar`H|C0A*eG3M` z;)&xn_Rp|klAR`{r}hdS^uTVRMS3M+Iw!_xD}BviQbE*%9}dGk!Z(ou z_s~QGVcU0hu)}#PHhbde`~>doE&TFG zYX&ZjCd&o_%vH+?s0E1=pR5PZ$DZ*!Z|);TFeNiqBPS3eGvtxRkzK0HM!)!u=~5Z2 zS3xeBKq@8(gFEt(d!g%bSQdEQNY@uX(KMri&a&xL`^4#!F970E;X^7l(^K&XRw^pl z|4P;aDqDQ*UP$`DgQ8f<;|qpsX+DOFuI3IzKYS9ImA7379u|7o8-L}BcU%+omJ?LN zTw(vW2y)~yR(pK}Cf5o1@rfqcH5>>jz5lg1cgZpGX@)YjtU)~Jp-Nwmk#!FhP?*KY zb6}!Qstev5HJm6}vz4wBhI>et)GPdgK zevkKrTX-)$lCzpZtP&!mf-gsq@MP+1prS4!@pVwxn@hn1g9Vbk$W>e+)~ zV`1p59i53ZZ9vNfj3cGzuXP9ZHkT`uSd}Xc_{BY>$Aa%GSY5@!jo+#xR-vrCSZ)wT z1}UclyJN~GDs32Nx`|DVV{*qvdFrB0uaexz*i$q)lCE_N)@d@5qApG3^Yz|_{^-O= z*v{RC#?fT%5%TF389=%?TVVWkY2lt`$OwK5&bU|Un}NFt=k~$^zS+xtwx{#W>%o%@ znwRr}RL!B;i9&>T_@%l0fVudboE%I1*ggv2oFJ7&BvhYV+K2^gpoz9}2Is?$af|Cg zi-zjrmnLvIC^);OkMWqKudV|1zQLa0%dmvrPNoQ|8PCUiJ%-E-VI1kcj~=AxSU&v= zx!4@8M(3<99+w%=?Tu=W)j}SIZ~!WJjD;0{djef@`>8gjGD<}_8Zt5)!!QCv!ufgd z;