feat(render3d): a bole that does not quake, leaves out of the lens, a light with a reach
layer_flutter's mask was the vertex's height alone, so on a ten metre aspen every part of the trunk above 1.2 m trembled with the leaves and the bole read as cloth. What separates a leaf from a bole is distance from the model's centre line, not height; the mask is radial now, and small plants keep the old one because a flower is all leaf. 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 and the equal-depth optimisation is untouched. gl_Position.w is the view depth, so it costs one varying and no uniform. Blades, cards and flowers are excluded - they live at the player's feet - and the shadow pass keeps every leaf. daylight_hand takes a reach in metres. The falloff was an inverse square windowed between 26 and 6 with both numbers hard-coded: two per cent of its own near field at ten metres, so a torch lit your boots. It is a gentle power out to the reach now. Measured in Maroon Lake, twice per side: the crown mask moves 1.7% of an aspen frame and nothing at all in the meadow under it; the near fade moves 7.1% of a first-person frame at a conifer and 0% where there is no foliage in the lens; the torch's reach lifts the lit ground 20 to 45% and leaves the sky bit-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a88aecb993
commit
18d23ca20d
8 changed files with 97 additions and 8 deletions
32
changes/leaves-light-and-the-bole.md
Normal file
32
changes/leaves-light-and-the-bole.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -364,9 +364,9 @@ function scatter_init() -> void {
|
|||
# R3D_DUMP_ATLAS: every impostor and card atlas the run bakes, to build/atlas_<n>_{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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue