From ae52e9c4dad0ad2e138c6d82bc007fa49ab0cc97 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sat, 19 Sep 2026 17:38:25 +0300 Subject: [PATCH] feat(render3d): anti-aliasing that exists, and a lens for the viewfinder The shipping default had NO anti-aliasing at all: the temporal resolve was removed, the setting's first option went on saying "Temporal", and MSAA defaults to one sample, so every machine without DLSS drew grass and needle cards with nothing smoothing an edge. FXAA in the sharpen pass, which already reads the neighbourhood and runs last on the LDR image. No history, so it cannot drag or smear a reflection. 25.5% less single-pixel staircase on edge pixels. The trap, recorded because it inverted the result: the unsharp delta must be computed from the RAW image and only then applied to the anti-aliased colour. Centre from FXAA and neighbours from the raw texture measures half smoothing and half signal, so the mask sharpens precisely what FXAA softened - 29% WORSE than no anti-aliasing at all. Depth of field for the photo mode: a disc whose radius is the circle of confusion, normalised by focus distance so a landscape shot is not a macro, with taps rejected unless they are at least as out of focus as the pixel they blur into - which is what stops a sharp foreground haloing into a blurred background. Between the scene and the bloom, so a blurred highlight still blooms. Skipped whole when the aperture is shut. 400 frames in ordinary play: GL 7.1 s, VK 7.2 s - the lens does not draw there. Co-Authored-By: Claude Opus 5 --- changes/a-lens-and-an-edge.md | 28 +++++++++ packages/ludic.render3d/post.ludic | 37 ++++++++++- packages/ludic.render3d/render.ludic | 1 + packages/ludic.render3d/shaders/dof.frag | 51 +++++++++++++++ packages/ludic.render3d/shaders/sharpen.frag | 58 ++++++++++++++++-- .../shaders/spv/4cf96468.frag.spv | Bin 6748 -> 11688 bytes .../shaders/spv/5161e6ba.frag.spv | Bin 0 -> 7376 bytes .../shaders/spv/5161e6ba.vert.spv | Bin 0 -> 1612 bytes .../ludic.render3d/shaders/spv/manifest.txt | 52 +++++++++++++++- packages/ludic.render3d/shaders/variants.list | 1 + 10 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 changes/a-lens-and-an-edge.md create mode 100644 packages/ludic.render3d/shaders/dof.frag create mode 100644 packages/ludic.render3d/shaders/spv/5161e6ba.frag.spv create mode 100644 packages/ludic.render3d/shaders/spv/5161e6ba.vert.spv diff --git a/changes/a-lens-and-an-edge.md b/changes/a-lens-and-an-edge.md new file mode 100644 index 00000000..bfd02d89 --- /dev/null +++ b/changes/a-lens-and-an-edge.md @@ -0,0 +1,28 @@ +bump: minor +type: feat +Anti-aliasing that exists, and a lens for the viewfinder. + +THE SHIPPING DEFAULT HAD NO ANTI-ALIASING AT ALL. The temporal resolve was removed (for +good reasons - it reprojected water through the surface plane and dragged the mirror image +behind the camera), the setting's first option went on saying "Temporal", and MSAA defaults +to one sample. Every machine without DLSS - which is every Mac - drew a frame full of grass +blades and needle cards with nothing smoothing a single edge. + +FXAA now, in the sharpen pass, because that pass already reads this pixel's neighbourhood +and runs last on the LDR image. It has no history, so it cannot drag or smear a reflection. +Measured on edge pixels: 25.5% less single-pixel staircase. + +One trap worth recording. The unsharp mask's delta is computed from the RAW image and only +then applied to the anti-aliased colour. Taking the centre from the FXAA result and the +neighbours from the raw texture measures a difference that is half smoothing and half +signal, so the mask sharpens exactly the edges FXAA just softened - measured, that first +version was 29% WORSE than no anti-aliasing at all. + +And depth of field, for the photo mode: a disc of taps whose radius is the pixel's circle +of confusion, signed so the two sides of the focal plane differ and normalised by the focus +distance, because a lens focused at two metres throws a background out far harder than one +focused at two hundred. A tap only counts if it is at least as out of focus as the pixel +it is blurring into, which is what keeps a sharp foreground from haloing into a blurred +background. It runs between the scene and the bloom so a blurred highlight still blooms, +and the whole pass is skipped when the aperture is shut - in ordinary play there is no lens +and this never draws. diff --git a/packages/ludic.render3d/post.ludic b/packages/ludic.render3d/post.ludic index 49905b68..d44437a9 100644 --- a/packages/ludic.render3d/post.ludic +++ b/packages/ludic.render3d/post.ludic @@ -76,6 +76,17 @@ var post_color_w: int = 0 # its size: the display's when DLSS upscaled var post_color_h: int = 0 var post_color: int = 0 # the HDR colour the rest of post reads # the resolved depth, copied so passes can read it while drawing into the frame var post_p_sharp: int = 0 +# Spatial anti-aliasing, in the sharpen pass because that pass already reads this pixel's +# neighbourhood and runs last on the LDR image. 1 on, 0 off; the game's setting drives it. +var post_fxaa: int = 0x3F800000 +# ---- depth of field ------------------------------------------------------------------------ +# Off in ordinary play - the pass is skipped whole, not run at zero radius. The game turns it on +# behind the viewfinder and says what to focus on. +var post_p_dof: int = 0 +var post_dof: Target = null +var post_dof_focus: int = 0x41200000 # 10 m +var post_dof_aperture: int = 0 # 0 = no lens, and no pass +var post_dof_max: int = 0x41400000 # 12 px var post_p_tone_hdr: int = 0 # the tonemap's HDR10 variant, made the first time HDR is on var post_ldr_hdr: bool = false # post_ldr was made for HDR10 output (10-bit) # the LDR image is 10-bit while the output is HDR10: PQ in 8 bits bands @@ -88,7 +99,7 @@ function post_free() -> void { if post_hdr == null { return } if post_ms_fbo != 0 { gpu_fb_free(post_ms_fbo); post_ms_fbo = 0 } target_free(post_hdr); target_free(post_ao); target_free(post_ao_blur); target_free(post_ldr) - target_free(post_depth_copy); target_free(post_prev); target_free(post_scene); target_free(post_vol) + target_free(post_depth_copy); target_free(post_prev); target_free(post_scene); target_free(post_vol); target_free(post_dof) for i in 0 .. len(post_bloom) { target_free(post_bloom[i]) } post_hdr = null } @@ -149,6 +160,8 @@ function post_init(w: int, h: int) -> void { post_ldr = target_new(w, h, post_ldr_fmt(), GL_RGBA, GL_UNSIGNED_BYTE, false, GL_LINEAR) post_ldr_hdr = gpu_hdr_active() post_depth_copy = target_new(w, h, GL_R8, GL_RED, GL_UNSIGNED_BYTE, true, GL_NEAREST) + post_dof = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR) + if post_p_dof == 0 { post_p_dof = r3d_program("fullscreen.vert", "dof.frag", "") } post_vol = target_new(max(w / 2, 1), max(h / 2, 1), GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR) if post_p_vol == 0 { post_p_vol = r3d_program("fullscreen.vert", "volumetric.frag", "") } post_prev = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR) @@ -335,6 +348,25 @@ function post_volumetric_pass() -> void { gpu_blend(false) } +# The lens, between the scene and the bloom: a blurred highlight should still bloom, and a +# bloom smeared by the lens afterwards would be a halo round nothing. +function post_dof_pass() -> void { + if post_dof_aperture == 0 { return } + gpu_depth_test(false) + gpu_blend(false) + target_bind(post_dof) + gpu_use_program(post_p_dof) + r3d_bind_2d(post_p_dof, "u_src", 0, post_color) + r3d_bind_2d(post_p_dof, "u_depth", 1, post_hdr.depth) + u_mat4(gpu_uniform(post_p_dof, "u_inv_proj"), cam_inv_proj) + u_f2(gpu_uniform(post_p_dof, "u_texel"), fr(1, post_w), fr(1, post_h)) + u_f(gpu_uniform(post_p_dof, "u_focus"), post_dof_focus) + u_f(gpu_uniform(post_p_dof, "u_aperture"), post_dof_aperture) + u_f(gpu_uniform(post_p_dof, "u_max_coc"), post_dof_max) + mesh_draw(post_fs) + post_color = post_dof.color +} + function post_bloom_pass() -> void { gpu_depth_test(false) gpu_blend(false) @@ -413,6 +445,9 @@ function post_tonemap(color_tex: int) -> void { r3d_bind_2d(post_p_sharp, "u_src", 0, post_ldr.color) u_f2(gpu_uniform(post_p_sharp, "u_texel"), fr(1, post_ldr.w), fr(1, post_ldr.h)) u_f(gpu_uniform(post_p_sharp, "u_amount"), post_sharpen) + var fx = post_fxaa + if r3d_env_has("R3D_NOFXAA") { fx = F_ZERO } + u_f(gpu_uniform(post_p_sharp, "u_fxaa"), fx) # R3D_NOGRAIN=1: no film grain, so two frames of a still camera can be compared for what else moves var grain = post_grain if r3d_env_has("R3D_NOGRAIN") { grain = F_ZERO } diff --git a/packages/ludic.render3d/render.ludic b/packages/ludic.render3d/render.ludic index 88a551b9..354e9df3 100644 --- a/packages/ludic.render3d/render.ludic +++ b/packages/ludic.render3d/render.ludic @@ -288,6 +288,7 @@ function r3d_frame(time: int) -> void { # Before bloom and before the tonemap: a shaft of light is a bright thing in the air and # should bloom like one. prof_begin("volumetric"); post_volumetric_pass(); prof_end() + prof_begin("dof"); post_dof_pass(); prof_end() if not post_no_gi { prof_begin("SSAO/GI"); post_ssao_pass(); prof_end() } if r3d_debug_max { tex_max(post_hdr.color, post_hdr.w, post_hdr.h, "hdr") } prof_begin("bloom") diff --git a/packages/ludic.render3d/shaders/dof.frag b/packages/ludic.render3d/shaders/dof.frag new file mode 100644 index 00000000..76fa0f57 --- /dev/null +++ b/packages/ludic.render3d/shaders/dof.frag @@ -0,0 +1,51 @@ +// Depth of field, for the viewfinder. The camera mode (the game's photo mode) has had a frame, +// a zoom and a grade and no LENS: everything from the bootlaces to the Bells was equally sharp, +// which is the one thing a photograph never is. +// +// A disc of taps whose radius is this pixel's circle of confusion. It runs at full resolution +// and costs nothing when the aperture is shut, because the whole pass is skipped - in ordinary +// play there is no lens and this never draws. +in vec2 v_uv; +out vec4 o_color; +uniform sampler2D u_src; +uniform sampler2D u_depth; +uniform mat4 u_inv_proj; +uniform vec2 u_texel; +uniform float u_focus; // metres to the plane in focus +uniform float u_aperture; // how fast the blur opens either side of it +uniform float u_max_coc; // in pixels, so a wide aperture cannot eat the whole frame + +float viewZ(vec2 uv) { + float d = texture(u_depth, uv).r; + vec4 p = u_inv_proj * vec4(uv * 2.0 - 1.0, d * 2.0 - 1.0, 1.0); + return -(p.z / p.w); +} + +void main() { + float z = viewZ(v_uv); + // The circle of confusion, signed so the two sides of the focal plane can be told apart, and + // normalised by the focus distance: a lens focused at two metres throws the background out + // far harder than one focused at two hundred, and a constant here made a landscape shot read + // as a macro. + float coc = (z - u_focus) / max(z, 0.1) * u_aperture; + float r = clamp(abs(coc), 0.0, 1.0) * u_max_coc; + if (r < 0.75) { o_color = vec4(texture(u_src, v_uv).rgb, 1.0); return; } + // a 16-point spiral, which reads as a lens rather than as a box blur + vec3 acc = texture(u_src, v_uv).rgb; + float wsum = 1.0; + for (int i = 0; i < 16; i++) { + float a = float(i) * 2.3999632; // golden angle + float rr = sqrt((float(i) + 0.5) / 16.0) * r; + vec2 off = vec2(cos(a), sin(a)) * rr * u_texel; + vec2 uv2 = v_uv + off; + // Do not drag a SHARP FOREGROUND pixel into a blurred background: that is the halo every + // naive depth-of-field has round a near object. A tap only counts if it is at least as far + // out of focus as this pixel is. + float z2 = viewZ(uv2); + float coc2 = abs((z2 - u_focus) / max(z2, 0.1) * u_aperture); + float w = smoothstep(0.0, 0.35, coc2 * u_max_coc / max(r, 1e-3)); + acc += texture(u_src, uv2).rgb * w; + wsum += w; + } + o_color = vec4(acc / wsum, 1.0); +} diff --git a/packages/ludic.render3d/shaders/sharpen.frag b/packages/ludic.render3d/shaders/sharpen.frag index 657d712c..c039ecc7 100644 --- a/packages/ludic.render3d/shaders/sharpen.frag +++ b/packages/ludic.render3d/shaders/sharpen.frag @@ -7,14 +7,64 @@ uniform float u_amount; uniform float u_grain; float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); } + +// ---- anti-aliasing ------------------------------------------------------------------------- +// THE SHIPPING DEFAULT HAS BEEN NO ANTI-ALIASING AT ALL. The temporal resolve was removed +// (post.ludic says why, and it was right to go: it reprojected water through the surface plane +// and dragged the mirror image behind the camera), the setting's first option went on saying +// "Temporal", and post_ms_samples defaults to 1 - so a frame full of grass blades and needle +// cards shimmered on every machine without DLSS, which is every Mac. +// +// This is a spatial pass with NO history: it cannot drag, it cannot smear a reflection, and it +// costs four taps on a pass that was already sampling its own neighbourhood for the unsharp +// mask. It does not fix sub-pixel grass shimmer - nothing spatial can - but it fixes every +// geometric edge, which is what the eye follows when you walk. +vec3 fxaa(vec2 uv, vec2 texel) { + vec3 rgbM = texture(u_src, uv).rgb; + float lM = luma(rgbM); + float lN = luma(texture(u_src, uv + vec2(0.0, texel.y)).rgb); + float lS = luma(texture(u_src, uv - vec2(0.0, texel.y)).rgb); + float lE = luma(texture(u_src, uv + vec2(texel.x, 0.0)).rgb); + float lW = luma(texture(u_src, uv - vec2(texel.x, 0.0)).rgb); + float lMin = min(lM, min(min(lN, lS), min(lE, lW))); + float lMax = max(lM, max(max(lN, lS), max(lE, lW))); + float range = lMax - lMin; + // a contrast floor, or film grain and dithering get "anti-aliased" into mush + if (range < max(0.0312, lMax * 0.125)) return rgbM; + // which way the edge runs, and which side of it is the step + bool isHorz = abs(lN + lS - 2.0 * lM) >= abs(lE + lW - 2.0 * lM); + vec2 dir = isHorz ? vec2(0.0, texel.y) : vec2(texel.x, 0.0); + float l1 = isHorz ? lN : lE; + float l2 = isHorz ? lS : lW; + if (abs(l1 - lM) < abs(l2 - lM)) dir = -dir; + vec3 a = texture(u_src, uv + dir * 0.5).rgb; + vec3 b = texture(u_src, uv + dir * 1.5).rgb; + vec3 blended = (a + b) * 0.5; + // Never let the blend leave the neighbourhood's own luma range. Without this guard a thin + // bright line - a blade against the sky, a rim on a trunk - reaches two pixels past where it + // belongs and reads as a halo, which is the failure everyone recognises as "this game has + // FXAA on". + float lB = luma(blended); + if (lB < lMin || lB > lMax) return mix(rgbM, a, 0.5); + return mix(rgbM, blended, 0.75); +} + +uniform float u_fxaa; void main() { - vec3 c = texture(u_src, v_uv).rgb; + // The unsharp mask's delta is computed from the RAW image and only then applied to the + // anti-aliased colour. Taking the centre from the FXAA result and the neighbours from the + // raw texture measures a difference that is half smoothing and half signal, so the mask + // sharpens precisely the edges FXAA just softened - measured, that made the frame's + // single-pixel reversal energy 29% WORSE than no anti-aliasing at all. + vec3 raw = texture(u_src, v_uv).rgb; + vec3 c = (u_fxaa > 0.5) ? fxaa(v_uv, u_texel) : raw; vec3 n = texture(u_src, v_uv + vec2(0, u_texel.y)).rgb, s = texture(u_src, v_uv - vec2(0, u_texel.y)).rgb; vec3 e = texture(u_src, v_uv + vec2(u_texel.x, 0)).rgb, w = texture(u_src, v_uv - vec2(u_texel.x, 0)).rgb; + float lraw = luma(raw); + float lb = (luma(n) + luma(s) + luma(e) + luma(w) + lraw * 4.0) / 8.0; + float d = clamp((lraw - lb) * u_amount, -0.08, 0.08); float lc = luma(c); - float lb = (luma(n) + luma(s) + luma(e) + luma(w) + lc * 4.0) / 8.0; - float d = clamp((lc - lb) * u_amount, -0.08, 0.08); - vec3 col = c * (1.0 + d / max(lc, 1e-3)); + vec3 col = c * (1.0 + d / max(lraw, 1e-3)); float g = (hash(v_uv * 1731.0 + fract(u_time)) - 0.5) * u_grain; col += g * (0.6 + 0.4 * (1.0 - lc)); o_color = vec4(clamp(col, 0.0, 1.0), 1.0); diff --git a/packages/ludic.render3d/shaders/spv/4cf96468.frag.spv b/packages/ludic.render3d/shaders/spv/4cf96468.frag.spv index 3ac548b8513053a6c73ab758297d352f793a75dc..92342924aae512dbb49cc1f76fbd27f9d8982ef3 100644 GIT binary patch literal 11688 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 literal 0 HcmV?d00001 diff --git a/packages/ludic.render3d/shaders/spv/5161e6ba.vert.spv b/packages/ludic.render3d/shaders/spv/5161e6ba.vert.spv new file mode 100644 index 0000000000000000000000000000000000000000..55a5473344437ce6ee3c76c7852439eaa953c12d GIT binary patch literal 1612 zcmYk6+fGwK6o$u^T~HK6P96|8;sLGJ6Cj8PM3X|oMGc9!4WT<`C)*97yM%a!7vB0F z`Vzi?59E!B|2KOUIL+$J`q#fsGpnsu=K7tx;0E1*TXW?aaTOs>Sk3Rvo!y;Ax9Bt$ z7N5Zwa#c@+Gwd$QmAFTMm)y)90KUDMw9u*6XDE?N3>o zAaac4GbsE}0*9EgeoT&vN4*w{GZ=3{Zt6s@ zOGcFypILdp<2<3$fc982DL$``jxCmcNxKHgGf0^H;bgSc*&eTkO8$!Piw3CxJq7L-=fLYxSrL(Lq?%ui6b^M>|gg>#Zju$#x zpD%-*TFkx_FuPygl)9HRKlsrji@1M;MA}z`VtX#BNjCLO>;(z+QX_fS#3R?D1e@_* z>1;gm8*f=>Y+scHzZD6-;NJrFI^evA#aq?c;*s~Qss)4lSKTa2tRL!SSwGAly)%o< zGCK7zbHftm?Wb}xTl{9gn7JS7nHt9>;LIB{X1)_T|L=QUIv8_jzBhCRH{X`>FoQQG z#Klf8)CERt?6)Og-vfUpVBCqth;>FsxKI3MC2(U2ahY*2i_3osaX*MpZ;vHlEW@7Y g491MKbX&|?Ljo6DJ;