diff --git a/changes/leaves-light-and-the-bole.md b/changes/leaves-light-and-the-bole.md new file mode 100644 index 00000000..10cce4ac --- /dev/null +++ b/changes/leaves-light-and-the-bole.md @@ -0,0 +1,32 @@ +bump: minor +type: feat +Three things a crown and a hand light were getting wrong. + +**An aspen's trunk quaked with its leaves.** `layer_flutter`'s mask was the vertex's +HEIGHT alone — anything more than 1.2 m up trembled — so on a ten metre aspen the whole +bole moved, and a tree that is supposed to shiver read as a column of cloth. What +separates a leaf from a bole is not height, it is distance from the model's own centre +line: the leaves are out at the edge of the crown and the trunk is on the axis. The mask +is radial now, and small plants keep the old one, because a flower is all leaf. + +**Foliage in the camera's face.** A third-person camera walks into a crown and the branch +between it and the body is an opaque wall a metre from the lens; in first person it is the +whole screen. `#define NEAR_FADE` dithers tree foliage out inside arm's length of the +camera — in the depth PREPASS, so the lit pass never sees those pixels either and the +equal-depth optimisation is untouched. `gl_Position.w` is the view depth for a perspective +projection, so it costs one varying and no uniform. Blades, cards and flowers are left +alone on purpose: they live at the player's feet, they are always that close, and fading +them opens a hole in the meadow. Nor is the BOLE ever faded: a trunk and its needle cards +are one mesh drawn by one program, so the first cut dissolved the trunk into a dither +pattern as you walked up to it - a solid tree you can see through reads as a fault, where +a branch getting out of your way reads as the camera being polite. The mask is the same +signal the quake uses. The shadow pass keeps every leaf, or a tree would stop shading the +ground it stands on as you walked up to it. + +**A hand light had the reach of a candle, whatever it was meant to be.** `handLight`'s +falloff was an inverse square windowed off between 26 and 6 metres, with both numbers +hard-coded: two per cent of its own near field by ten metres out, so carrying a torch at +night lit your boots and nothing else. `daylight_hand` takes a `reach` in metres now and +the falloff is a gentle power out to it — about nine tenths at a metre, half at half the +reach, a tenth at nine tenths of it, nothing past it. A pool of light with a gradient in +it rather than a hotspot with a cliff. diff --git a/packages/ludic.render3d/FEATURES.md b/packages/ludic.render3d/FEATURES.md index e64fafdb..9f735eef 100644 --- a/packages/ludic.render3d/FEATURES.md +++ b/packages/ludic.render3d/FEATURES.md @@ -90,7 +90,8 @@ Characters and moving objects 61. Static collision: ground circles in a cell grid with push-out and a segment test — `collide.ludic` 62. Cut-out and emissive actors (a flame's cards) — `actor.ludic`, `shaders/model.frag` `u_emissive` 63. Per-instance skin clones (a herd posed individually), per-part tints and hidden parts on actors, uniform-location caching with frustum and distance culling — `skin.ludic` `skin_clone`, `actor.ludic` -64. A second point / cone light (a torch or flashlight in the hand) — `daylight.ludic` `daylight_hand`, `shaders/lighting.glsl` `handLight` +64. A second point / cone light (a torch or flashlight in the hand) with a reach in metres and a gentle falloff to it — `daylight.ludic` `daylight_hand`, `shaders/lighting.glsl` `handLight` +64a. Tree foliage dithered out inside arm's length of the camera, in the depth prepass — `shaders/model.vert` `v_near`, `shaders/depth.frag`, `#define NEAR_FADE` 65. Auto-exposure adapted on the GPU: a 1x1 pass eases last frame's value toward key / mean and the tonemapper samples it, so nothing is read back (a pixel-buffer read still synchronised on Apple's GL) — `post.ludic` `post_measure`, `shaders/adapt.frag` 66. A PPM reader with box-filtered downscale (photo thumbnails) and word-wrapped overlay text — `texture.ludic` `tex_load_ppm`, `overlay.ludic` `ov_text_wrap` 67. The overlay batches a whole frame into one upload and draws per texture range with optional scissor clips (per-change uploads stalled the driver: 40 ms -> 25 ms), plus nine-slice, rotated, line, disc and arc primitives — `overlay.ludic` `ov_flush`, `ov_clip`, `ov_nine`, `ov_sub_rot`, `ov_line`, `ov_disc`, `ov_arc` diff --git a/packages/ludic.render3d/daylight.ludic b/packages/ludic.render3d/daylight.ludic index 7b404a56..fe0d0e17 100644 --- a/packages/ludic.render3d/daylight.ludic +++ b/packages/ludic.render3d/daylight.ludic @@ -37,6 +37,7 @@ var hand_pos: words = null var hand_color: words = null var hand_dir: words = null var hand_cone: int = 0 +var hand_reach: int = 0 # metres the hand light reaches (float bits) var day_overcast: int = 0 # 0 clear .. 1 a low grey sky (float bits): dims the sun and the sky's light var day_flash: int = 0 # a lightning flash this frame (0..1): the sun brightens for it var day_fog_mul: int = 0x3F800000 # multiplies the base fog density (rain and snow thicken the air) @@ -48,7 +49,7 @@ function daylight_init() -> void { fire_pos = v3_new(F_ZERO, fi(-1000), F_ZERO) fire_color = v3_new(F_ZERO, F_ZERO, F_ZERO) day_moon_dir = v3_new(F_ZERO, F_ONE, F_ZERO) - hand_pos = v3_new(F_ZERO, fi(-1000), F_ZERO); hand_color = v3_new(F_ZERO, F_ZERO, F_ZERO); hand_dir = v3_new(F_ZERO, F_ZERO, f_neg(F_ONE)); hand_cone = f_neg(F_TWO) + hand_pos = v3_new(F_ZERO, fi(-1000), F_ZERO); hand_color = v3_new(F_ZERO, F_ZERO, F_ZERO); hand_dir = v3_new(F_ZERO, F_ZERO, f_neg(F_ONE)); hand_cone = f_neg(F_TWO); hand_reach = fi(12) day_light = F_ONE } @@ -274,15 +275,22 @@ function daylight_fire(x: int, y: int, z: int, strength: int) -> void { } # the light in the hand: a point light (cone < -1) or a cone along dir (cone = cos half-angle) -function daylight_hand(x: int, y: int, z: int, dx: int, dy: int, dz: int, cone: int, r: int, g: int, b: int) -> void { +# A torch or a flashlight in the hand. `reach` is how far it carries, in metres: the +# falloff is a gentle power out to it and nothing past it (lighting.glsl handLight), which +# is what a pool of firelight looks like. It used to be a windowed inverse square with the +# window and the scale both hard-coded, so every hand light in every game had the reach of +# a candle whatever it was meant to be. +function daylight_hand(x: int, y: int, z: int, dx: int, dy: int, dz: int, cone: int, reach: int, r: int, g: int, b: int) -> void { v3_set(hand_pos, x, y, z); v3_set(hand_dir, dx, dy, dz); hand_cone = cone v3_set(hand_color, r, g, b) + hand_reach = reach } function daylight_bind(prog: int) -> void { u_v3(gpu_uniform(prog, "u_hand_pos"), hand_pos) u_v3(gpu_uniform(prog, "u_hand_color"), hand_color) u_v3(gpu_uniform(prog, "u_hand_dir"), hand_dir) u_f(gpu_uniform(prog, "u_hand_cone"), hand_cone) + u_f(gpu_uniform(prog, "u_hand_reach"), hand_reach) u_v3(gpu_uniform(prog, "u_ibl_scale"), day_ibl) u_v3(gpu_uniform(prog, "u_moon_dir"), day_moon_dir) u_f(gpu_uniform(prog, "u_moon_phase"), day_moon_phase) diff --git a/packages/ludic.render3d/scatter.ludic b/packages/ludic.render3d/scatter.ludic index 551c88a1..94638fbf 100644 --- a/packages/ludic.render3d/scatter.ludic +++ b/packages/ludic.render3d/scatter.ludic @@ -364,9 +364,9 @@ function scatter_init() -> void { # R3D_DUMP_ATLAS: every impostor and card atlas the run bakes, to build/atlas__{color,alpha}.ppm sc_debug_dump = r3d_env_has("R3D_DUMP_ATLAS") sc_prog = r3d_program("model.vert", "model.frag", "") - sc_prog_fol = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define ALPHA_TEST\n") - sc_prog_fol_depth = r3d_program("model.vert", "depth.frag", "#define FOLIAGE\n#define WIND\n#define ALPHA_TEST\n") - sc_prog_fol_eq = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define EQ_PASS\n") + sc_prog_fol = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define ALPHA_TEST\n#define NEAR_FADE\n") + sc_prog_fol_depth = r3d_program("model.vert", "depth.frag", "#define FOLIAGE\n#define WIND\n#define ALPHA_TEST\n#define NEAR_FADE\n") + sc_prog_fol_eq = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define EQ_PASS\n#define NEAR_FADE\n") sc_prog_wind = r3d_program("model.vert", "model.frag", "#define WIND\n") sc_prog_blade = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define BLADE\n") sc_prog_flower = r3d_program("model.vert", "model.frag", "#define FOLIAGE\n#define WIND\n#define FLOWER\n") diff --git a/packages/ludic.render3d/shaders/depth.frag b/packages/ludic.render3d/shaders/depth.frag index 54f5a76d..7bfbe380 100644 --- a/packages/ludic.render3d/shaders/depth.frag +++ b/packages/ludic.render3d/shaders/depth.frag @@ -12,9 +12,17 @@ // because without MSAA a fragment is either there or not. in vec3 v_wpos; in vec2 v_uv; +in float v_near; uniform sampler2D u_diff; void main() { if (v_wpos.y < u_clip_y) discard; +#ifdef NEAR_FADE + // Leaves in the camera's face (model.vert's v_near). Dithered out rather than blended: + // no sorting, no second pass and no halo, and because it happens in the PREPASS the lit + // pass never sees those pixels either - its equal-depth test simply finds nothing to + // match. A branch a metre from the lens stops being an opaque wall in front of the body. + if (v_near < 0.999 && v_near < fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453)) discard; +#endif vec4 d = texture(u_diff, v_uv); float lod = textureQueryLod(u_diff, v_uv).x; float a = min(d.a * (1.0 + 0.45 * max(lod, 0.0)), 1.0); diff --git a/packages/ludic.render3d/shaders/lighting.glsl b/packages/ludic.render3d/shaders/lighting.glsl index 182f765f..87bb229c 100644 --- a/packages/ludic.render3d/shaders/lighting.glsl +++ b/packages/ludic.render3d/shaders/lighting.glsl @@ -63,6 +63,7 @@ uniform vec3 u_hand_pos; // a torch or flashlight in the hand uniform vec3 u_hand_color; uniform vec3 u_hand_dir; uniform float u_hand_cone; // cos of the half-angle; <= -1: a point light +uniform float u_hand_reach; // metres to where it dies out entirely vec3 skyIrradianceRaw(vec3 n) { return texture(u_irradiance, equirectUV(n)).rgb * u_ibl_scale; } vec3 skyIrradiance(vec3 n) { vec3 up = skyIrradianceRaw(vec3(n.x, max(n.y, 0.0), n.z)); @@ -88,7 +89,13 @@ vec3 handLight(vec3 wpos, vec3 n, vec3 albedo) { vec3 d = u_hand_pos - wpos; float r2 = max(dot(d, d), 0.04); vec3 l = d * inversesqrt(r2); - float att = smoothstep(26.0, 6.0, sqrt(r2)) / (0.5 + r2 * 0.35); + // A torch is not a point source in free air, and lighting it as one made it useless. + // The old curve was an inverse square windowed off between 26 and 6 metres: two per cent + // of its own near field by ten metres out, so carrying fire at night lit your boots and + // nothing else. This is a reach and a gentle power falloff instead - about nine tenths at + // a metre, half at half the reach, a tenth at nine tenths of it, nothing past it - which + // is a pool of light with a gradient in it rather than a hotspot with a cliff. + float att = pow(max(1.0 - sqrt(r2) / max(u_hand_reach, 0.001), 0.0), 1.25); if (u_hand_cone > -1.0) { float c = dot(-l, u_hand_dir); att *= smoothstep(u_hand_cone, u_hand_cone + 0.12, c); diff --git a/packages/ludic.render3d/shaders/model.frag b/packages/ludic.render3d/shaders/model.frag index db536c38..14448b38 100644 --- a/packages/ludic.render3d/shaders/model.frag +++ b/packages/ludic.render3d/shaders/model.frag @@ -5,6 +5,7 @@ in float v_seed; in vec2 v_rot; in float v_hull; in float v_quake; // an aspen leaf turning its pale side up +in float v_near; // 0 in the camera's face, 1 past arm's length (NEAR_FADE) uniform float u_model_h; out vec4 o_color; uniform sampler2D u_diff; @@ -38,6 +39,10 @@ void main() { // early depth rejection off and bring the overdraw back, and the prepass has already // cut both the clip plane and the alpha) if (v_wpos.y < u_clip_y) discard; +#ifdef NEAR_FADE + // the same dither as the prepass, for the draws that have no prepass in front of them + if (v_near < 0.999 && v_near < fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453)) discard; +#endif #endif vec3 N = normalize(v_nrm); if (!gl_FrontFacing && v_hull >= 0.0) N = -N; diff --git a/packages/ludic.render3d/shaders/model.vert b/packages/ludic.render3d/shaders/model.vert index 643c55ea..c3149d97 100644 --- a/packages/ludic.render3d/shaders/model.vert +++ b/packages/ludic.render3d/shaders/model.vert @@ -59,6 +59,7 @@ out float v_hull; // 0 at the crown's axis .. 1 at its rim // varying so the fragment stage can flash the leaf's pale underside as it turns - which is // the part you actually SEE. A still frame of a tremble is a still frame of nothing. uniform float u_flutter; +out float v_near; // 0 in the camera's face, 1 past arm's length (NEAR_FADE) out float v_quake; void main() { float s = i_rot.x, c = i_rot.y; @@ -104,7 +105,14 @@ void main() { // the whole tree shivering. float qp = u_time * 8.5 + a_pos.x * 11.3 + a_pos.z * 9.7 + a_pos.y * 4.1 + i_rot.z * 6.2831; float q = sin(qp) * 0.65 + sin(qp * 1.73 + 1.1) * 0.35; - float amp = u_flutter * (0.40 + 0.60 * windGust(i_pos.xz, u_time)) * smoothstep(0.0, 1.2, hgt); + // ...and only out on the CROWN. The mask was height alone, so on an aspen every vertex + // of a ten metre bole above 1.2 m trembled along with the leaves and the TRUNK moved + // like cloth. What separates a leaf from a bole is not height, it is distance from the + // model's own centre line: the leaves are out at the edge of the crown and the trunk is + // on the axis. Small plants keep the old mask - a flower is all leaf. + float rmask = 1.0; + if (u_model_h > 2.0) rmask = smoothstep(0.22, 0.75, length(a_pos.xz) / max(u_model_h * 0.22, 0.05)); + float amp = u_flutter * (0.40 + 0.60 * windGust(i_pos.xz, u_time)) * smoothstep(0.0, 1.2, hgt) * rmask; p.x += q * amp * 0.10; p.y += q * amp * 0.05; p.z += cos(qp * 0.9) * amp * 0.10; @@ -133,5 +141,25 @@ void main() { gl_Position = u_light_vp * vec4(w, 1.0); #else gl_Position = u_proj * u_view * vec4(w, 1.0); +#endif + // Leaves in the camera's face. A third-person camera walks through a crown and the + // branch between it and the body is an opaque wall a metre from the lens; in first + // person it is the whole screen. Tree foliage within arm's length of the CAMERA fades + // out - w is the view-space depth for a perspective projection, so this costs nothing + // and needs no uniform. Blades, cards and flowers are excluded on purpose: they live at + // the player's feet, they are always this close, and fading them opens a hole in the + // meadow. The shadow pass keeps every leaf, or a tree would stop shading the ground it + // is standing on as you walked up to it. + v_near = 1.0; +#if defined(NEAR_FADE) && !defined(SHADOW_PASS) + // The BOLE is never faded, only what hangs off it. A conifer's trunk and its needle + // cards are one mesh drawn by one program, so the first cut dissolved the trunk into a + // dither pattern as you walked up to it - a solid tree you can see through reads as a + // fault, where a branch getting out of your way reads as the camera being polite. Same + // signal as the quake's crown mask: the trunk is on the model's axis and the foliage is + // out from it. + float ax = 1.0; + if (u_model_h > 2.0) ax = smoothstep(0.12, 0.30, length(a_pos.xz) / max(u_model_h * 0.22, 0.05)); + v_near = mix(1.0, clamp((gl_Position.w - 0.5) / 1.7, 0.0, 1.0), ax); #endif }