feat(gl): OpenGL 4.1 and the ludic.render3d renderer
Some checks failed
ci / build-and-test (push) Waiting to run
commit-lint / conventional-commits (push) Waiting to run
bootstrap / cfree-fixpoint (push) Has been cancelled
docs / build-and-deploy (push) Successful in 34s

`Gl.*` binds the whole OpenGL 4.1 core API — every entry point of the
platform gl3.h with every GL_* constant, generated by `ludic-dev glgen`
with per-call ABI thunks. Windowed builds get an NSOpenGLContext on the
existing window at Retina resolution; headless builds render into an
offscreen CGL context, so a program that uses Gl.* renders and
screenshots identically under the test harness. It links gl.ll, the
thunks and OpenGL.framework only when used; every other build stays
byte-identical.

packages/ludic.render3d is a physically based renderer written on that
surface: HDRI image-based lighting, GPU-generated terrain with scanned
PBR materials, CDLOD, cascaded shadows, glTF with skinning, instanced
vegetation with impostors, procedural grass, water, SSAO, and an HDR
pipeline with bloom, auto-exposure and ACES.

It also carries this session's work on it: the terrain at half its cost
(10.3 -> 5.4 ms of frame), the streaming hitch that got worse the longer
you played, a resize that emptied the world, and the packaging that lets
a game use the renderer from its own repository — `ludic assets`, the
material manifest shipping with the package, and shader lookup falling
back to the install root. See changes/ for each, with its numbers.

The camping game that drove all of it has moved out to its own
repository, Maroon Lake; examples/rendering/smooth.ludic stays as the
renderer's example here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-10 03:31:12 +03:00
parent 470971bf70
commit f25289db20
90 changed files with 35316 additions and 19853 deletions

View file

@ -0,0 +1,118 @@
# ludic.render3d — what is implemented, where, and how it compares
Every item below names the file (and function or shader) that implements it, so
the claim can be checked in the tree. Paths are relative to `packages/ludic.render3d`
unless stated; `Gl.*` lives in `runtime/native/`.
## The OpenGL surface (`Gl.*`)
- **Every OpenGL 4.1 core entry point** — 478 of 478 `GLAPI` declarations in the
platform `gl3.h`, generated by `ludic-dev glgen` (`tools/ludic-cli/glgen.ludic`) into
`runtime/native/gl_api.ludic` (`grep -c '^extern function gl_'` = 478) with one ABI
thunk each in `runtime/native/gl_thunks.ll`; **901 `GL_*` constants**. Float and
double parameters take `fixed`; 64-bit sizes take `long`; `GLboolean` returns are
widened. macOS exposes 4.1 core as its ceiling, so this is the whole API the platform has.
- Context on the window at Retina resolution (`runtime/native/cocoa.ll`,
`win_gl_attach`), offscreen CGL context for headless runs (`runtime/native/gl.ll`),
screenshots, shader/program helpers, IEEE-float helpers over Q16.16 (`runtime/native/gl.ludic`).
## Rendering features (numbered so they can be counted)
Lighting and materials
1. Cook-Torrance GGX specular, Smith height-correlated visibility, Schlick Fresnel — `shaders/lighting.glsl` `shade()`
2. Diffuse image-based lighting from a convolved irradiance map — `sky.ludic` `sky_precompute`, `shaders/ibl_irradiance.frag`
3. GGX-prefiltered specular environment, 6 roughness levels (importance sampled, mip-filtered) — `shaders/ibl_prefilter.frag`
4. Split-sum BRDF lookup table — `shaders/ibl_brdf.frag`
5. HDRI sky with the sun extracted from the map and its irradiance integrated from the clipped texels — `texture.ludic` `hdr_decode`, `sky.ludic`
6. Sky rotation with all convolutions rebuilt (time-of-azimuth control) — `sky.ludic` `sky_set_yaw`
7. Normal mapping with derivative-reconstructed tangent frames (models) and a heightfield tangent basis (terrain) — `shaders/model.frag` `cotangentFrame`, `shaders/terrain.frag`
8. Distant-grass carpet: the clump cards baked straight down into a tiling tile (alpha = coverage) and draped on the ground beyond the blade rings with stochastic anti-tiling, so the far meadow carries the near cover's clumps and gaps — `scatter.ludic` `carpet_bake`, `shaders/terrain.frag` `sampleCarpet` (replaced the parallax pass, whose sampler slot it took)
9. Stochastic (triangle-grid) anti-tiling texture sampling with rotated taps and `textureGrad` — `shaders/terrain.frag` `sampleMat`
10. Triplanar mapping for steep rock — `shaders/terrain.frag` `sampleTri`
11. Foliage translucency and wrapped diffuse (thin-leaf subsurface approximation) — `shaders/model.frag` `FOLIAGE`
12. Two-sided lighting for thin cards — `shaders/model.frag` `CARD`
13. Ground-bounce fill light (one bounce from the sunlit meadow onto undersides) — `shaders/lighting.glsl` `bounce`
14. Screen-space global illumination: one indirect diffuse bounce gathered from the previous anti-aliased frame, plus ambient occlusion, half resolution, depth-aware blur — `shaders/ssgi.frag`, `shaders/ssao_blur.frag`, `post.ludic` `post_ssao_pass`
15. Specular occlusion from ambient occlusion — `shaders/lighting.glsl`
16. Cloud shadows projected along the sun from an animated cloud layer — `shaders/lighting.glsl` `cloudShadow`
17. Regional vegetation colouring (aspen groves, dry ridges) and slope-aspect colouring — `shaders/lighting.glsl` `regionTint`, `shaders/terrain.frag`
Shadows
18. Cascaded shadow maps, 5 cascades (16 / 60 / 250 / 1100 / 6000 m) fitted to frustum-slice bounding spheres, texel-snapped, PCF radius widened on the far cascades — `shadow.ludic` `shadow_fit`
19. PCF with rotated Poisson taps and interleaved-gradient noise, normal-offset and world-unit slope bias, cascade edge blending — `shaders/lighting.glsl` `sunShadow`
20. Per-layer caster culling (grass in near cascades, coarse terrain meshes for far cascades) — `scatter.ludic` `scatter_draw_casters`, `terrain.ludic`
21. Sun-facing impostor cards in the shadow pass with mip-biased coverage — `shaders/impostor.vert`, `shaders/impostor.frag`
Geometry, terrain and world
22. GPU-generated terrain height field (ridged multifractal, fbm) — `shaders/heightgen.frag`
23. Real-world terrain from the Copernicus GLO-30 elevation model with slope-scaled fractal detail; a lake bed carved under the surface the model records — the survey window in the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake), `shaders/heightgen.frag` `DEM`, `terrain.ludic` `terrain_use_dem`, `terrain_lake`
24. Satellite orthophoto drape (Sentinel-2 true colour) sampled bicubically, blended in past the middle ground, keeping the scanned materials' luminance grain plus three-scale procedural grain — the survey window in the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake), `shaders/terrain.frag` `u_ortho`
25. Satellite-classified materials and placement: bare ground (cobble shore near, talus far), snow, meadow, dense conifer stands (tree density and forest-floor duff follow the photograph) — `shaders/terrain.frag`, `terrain.ludic` `terrain_ortho_green/scree/forest`
26. Terrain material splatting by slope, altitude and a track mask; height-map CPU read-back for placement queries — `shaders/terrain.frag`, `terrain.ludic` `terrain_height`
27. Snow by altitude and slope, gully snow, forest-floor material under stands — `shaders/terrain.frag`
28. glTF 2.0 loading of photogrammetry models by byte-range reads from the `.bin` — `gltf.ludic`
29. GPU instancing with divisor attributes and per-instance transforms — `scatter.ludic`, `shaders/model.vert`
30. Baked impostors (16-view atlas with coverage and normals, hull-normal blending, LOD switch) — `scatter.ludic` `impostor_bake`, `shaders/impostor.*`
31. Baked branch-card trees: wedge slices of a scanned crown (sectors × height bands) on radial cards over the real trunk mesh — `scatter.ludic` `impostor_bake_wedges`, `model_branch_cards`
32. Card atlases baked from scanned grass clumps and a dense procedural lupine, instanced as crossed cards — `scatter.ludic` `layer_cards`, `model_lupine_dense`
33. Procedural grass blades with wind, dryness and base occlusion; distant blades widen, lie flat to the ground normal and sink into the carpet at the ring's edge — `scatter.ludic` `model_blade`, `shaders/model.vert`, `shaders/model.frag` `BLADE`
34. Wind animation (height-weighted sway, per-instance phase) — `shaders/model.vert` `WIND`
35. Chunk-streamed ground cover with distance bands (blades in four rings, densest underfoot), deterministic per-chunk generation, nearest-first amortised over frames with an instance budget — `stream.ludic`
36. Multi-scale noise clustering for natural placement, forest density by elevation/slope/aspect, avalanche chutes, shoreline scrub — the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake)
37. Level-of-detail by distance: full scan mesh → branch cards → single impostor — `scatter.ludic` `layer_update`
38. Texture edge dilation for cut-out atlases; 16-bit PNG decoding to RGB16/R16; RGBE HDR decoding — `texture.ludic`
39. Anisotropic filtering and mipmapping on all scanned maps — `texture.ludic` `tex_upload`
40. Still water: planar reflection pass through a reflection matrix with clip-below discard, depth-tinted body, soft shores, ripple normals, foam streaks, sun glitter; the surface writes depth so occlusion and temporal passes see it — `water.ludic`, `shaders/water.frag`
Anti-aliasing, camera and post-processing
41. Temporal anti-aliasing: Halton-jittered projection, depth reprojection, YCoCg neighbourhood clamping, history ping-pong — `camera.ludic` `cam_set_jitter`, `shaders/taa.frag`
42. Optional MSAA with alpha-to-coverage and per-mip alpha sharpening — `post.ludic`, `shaders/impostor.frag`
43. HDR pipeline (RGBA16F) with NaN/inf sanitising at every stage — `post.ludic`, `shaders/lighting.glsl` `sane`
44. Bloom: 13-tap downsample pyramid with soft threshold, tent upsample — `shaders/bloom_down.frag`, `shaders/bloom_up.frag`
45. Auto-exposure by mean luminance with temporal adaptation — `post.ludic` `post_measure`
46. ACES filmic tonemapping with log-space contrast — `shaders/tonemap.frag`
47. Colour grading: white balance, lift/gain, saturation — `shaders/tonemap.frag`
48. Vignette, dithering, film grain — `shaders/tonemap.frag`, `shaders/sharpen.frag`
49. Luma unsharp sharpening — `shaders/sharpen.frag`
50. Height-based exponential fog / aerial perspective from the prefiltered sky with sun inscatter — `shaders/lighting.glsl` `applyFog`
51. Specular anti-aliasing (highlight cap) and firefly prevention — `shaders/lighting.glsl`
52. Fly camera with mouse look — the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake)
53. Profiling switches per stage and a frame benchmark — `render.ludic` `r3d_env_flags`
Characters and moving objects
54. glTF skins: node hierarchy, joints and inverse bind matrices loaded per model, four-influence GPU skinning, poses set per node in the model's frame — `skin.ludic`, `shaders/skin.vert`, `quat.ludic`
55. Actors: models placed and moved per frame (skinned or rigid), lit and cast into every cascade — `actor.ludic`
56. The drawn terrain height on the CPU (cubic B-spline of the texels) for things that stand on it — `terrain.ludic` `terrain_height_smooth`
57. A third-person character controller with a distance-driven procedural gait (walk / run / idle), slope and water limits, and a settling orbit camera — the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake)
58. A 2D overlay over the finished frame: batched rectangles, images and proportional text from a baked font atlas (`tools/blender/font_build.py`) — `overlay.ludic`, `shaders/overlay.*`
59. Time of day over the HDRI: a solar arc drives the sun's direction and colour, the sky's light scales toward a deep-blue starry night, a moon lights the night, the visible sky turns with the sun and its convolutions rebake when far off, the height-field shadow rebakes as the sun moves, auto-exposure is capped at night — `daylight.ludic`, `shaders/sky.frag`, `shaders/lighting.glsl`
60. One point light (the campfire) in the shading — `shaders/lighting.glsl` `fireLight`
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`
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`
68. Weather on the daylight: an overcast factor greys and dims the sun and the sky's light, a fog multiplier thickens the air, a lightning flash — `daylight.ludic` `daylight_weather`
## Against Unreal Engine 5 and RAGE (RDR2), honestly
| Technique | UE5 / RAGE | Here |
|---|---|---|
| Physically based shading, IBL, split-sum | both | implemented (1–4) |
| Cascaded shadow maps with PCF | both (UE5 also virtual shadow maps) | implemented (18–21); no virtual shadow maps |
| Temporal AA / upsampling | TSR / TAA | TAA implemented (41), no upscaling |
| Screen-space AO / GI | SSAO, SSGI, Lumen | SSGI + AO implemented (14); no Lumen-class GI, no ray tracing (not available on OpenGL 4.1) |
| Volumetric clouds and fog | both | HDRI sky with cloud shadows and analytic fog; no ray-marched clouds |
| Foliage: impostors, wind, translucency | both | implemented (30–34) |
| Nanite / virtualised geometry | UE5 | not applicable; LOD chain (37) instead |
| Terrain: height fields, layered materials, real-world data | both | implemented (22–27) |
| Water with planar reflection | RAGE planar, UE SSR | planar reflection implemented (40); no screen-space reflection |
| Post: bloom, exposure, tonemap, grading, DoF, motion blur | both | bloom/exposure/tonemap/grading implemented (44–49); no depth of field, no motion blur |
| Streaming world | both | chunk streaming of cover (35); terrain is one 8 km tile |
| Compute shaders, indirect draw, bindless | both | not available in OpenGL 4.1 core on macOS |
Where the remaining visual gap sits: photogrammetry cards for several meadow
species, ray-traced or probe-based global illumination, virtual shadow maps, and
volumetric clouds. Those are the techniques that separate this from the reference.

View file

@ -0,0 +1,204 @@
# ============================================================================
# actor.ludic — a model placed once and moved every frame (a character, a prop
# that animates): the counterpart of a scatter Layer for the handful of things
# that are not instanced. Skinned models pose through their Skin (skin.ludic);
# rigid ones take the same path with u_skinned = 0. The game draws them from
# scene_draw / scene_draw_casters through actor_draw / actor_draw_casters.
#
# Uniform locations are looked up once per program (glGetUniformLocation by
# name is slow on this driver, and a hundred actors over five cascades made it
# the frame's biggest CPU cost); the scene's lighting is bound once per program
# per pass, and actors outside the view or beyond their cull distance are skipped.
# ============================================================================
property Actor {
model: Model,
x: int = 0, # float bits, metres
y: int = 0,
z: int = 0,
yaw: int = 0, # radians; 0 faces -z like the camera
scale: int = 0,
tint: words,
rough: int = 0,
mat: words, # the model matrix
visible: bool = true,
casts: bool = true,
id: int = 0,
cutout: bool = false, # alpha-tested (a flame's cards)
emissive: int = 0, # float bits: self-lit strength
skin: Skin, # this instance's own pose (skin_clone); null: the model's
ptint: words, # 4 per primitive: on flag, r, g, b (a part's own colour)
hide: words, # 1 per primitive: skip it (a cap taken off)
radius: int = 0, # float bits: bounding radius for culling (0 = never culled)
cull: int = 0 # float bits: not drawn beyond this distance (0 = always)
}
# a program and its uniform locations
property AcProg {
prog: int = 0,
l_model: int = -1,
l_bones: int = -1,
l_skin: int = -1,
l_lvp: int = -1,
l_tint: int = -1,
l_rough: int = -1,
l_emis: int = -1,
l_view: int = -1,
l_proj: int = -1,
l_mh: int = -1,
scene_frame: int = -1 # the frame its scene uniforms were bound
}
var ac_lit: AcProg = null
var ac_lit_cut: AcProg = null
var ac_sh: AcProg = null
var ac_sh_cut: AcProg = null
var ac_actors: []Actor = null
var ac_next_id: int = 0
var ac_frame: int = 0
function ac_prog_new(vs: string, fs: string, defs: string) -> AcProg {
let a = new AcProg
a.prog = r3d_program(vs, fs, defs)
let p = a.prog
a.l_model = gl_uniform(p, "u_model"); a.l_skin = gl_uniform(p, "u_skinned"); a.l_lvp = gl_uniform(p, "u_light_vp")
a.l_bones = gl_uniform(p, "u_bones[0]"); if a.l_bones < 0 { a.l_bones = gl_uniform(p, "u_bones") }
a.l_tint = gl_uniform(p, "u_tint"); a.l_rough = gl_uniform(p, "u_rough_scale"); a.l_emis = gl_uniform(p, "u_emissive")
a.l_view = gl_uniform(p, "u_view"); a.l_proj = gl_uniform(p, "u_proj"); a.l_mh = gl_uniform(p, "u_model_h")
# the samplers never move: units 0, 1, 2
gl_use_program(p)
gl_uniform1i(gl_uniform(p, "u_diff"), 0); gl_uniform1i(gl_uniform(p, "u_nrm"), 1); gl_uniform1i(gl_uniform(p, "u_arm"), 2)
return a
}
function actor_init() -> void {
ac_lit = ac_prog_new("skin.vert", "model.frag", "")
ac_lit_cut = ac_prog_new("skin.vert", "model.frag", "#define ALPHA_TEST\n")
ac_sh = ac_prog_new("skin.vert", "shadow.frag", "#define SHADOW_PASS\n")
ac_sh_cut = ac_prog_new("skin.vert", "shadow.frag", "#define SHADOW_PASS\n#define ALPHA_TEST\n")
ac_actors = new []Actor
}
function actor_remove(a: Actor) -> void {
if ac_actors == null { return }
let keep = new []Actor
for i in 0 .. len(ac_actors) { if ac_actors[i].id != a.id { push(keep, ac_actors[i]) } }
ac_actors = keep
}
# colour one named part of the model (a material name from the file)
function actor_tint_part(a: Actor, name: string, r: int, g: int, b: int) -> void {
if a.model == null { return }
let n = len(a.model.prims)
if a.ptint == null { a.ptint = words(n * 4); for i in 0 .. n * 4 { a.ptint[i] = 0 } }
for i in 0 .. n { if a.model.prims[i].name == name { a.ptint[i * 4] = 1; a.ptint[i * 4 + 1] = r; a.ptint[i * 4 + 2] = g; a.ptint[i * 4 + 3] = b } }
}
function actor_hide_part(a: Actor, name: string, hidden: bool) -> void {
if a.model == null { return }
let n = len(a.model.prims)
if a.hide == null { a.hide = words(n); for i in 0 .. n { a.hide[i] = 0 } }
var v = 0
if hidden { v = 1 }
for i in 0 .. n { if a.model.prims[i].name == name { a.hide[i] = v } }
}
function actor_new(model: Model) -> Actor {
let a = new Actor
a.model = model
a.scale = F_ONE
a.tint = v3_new(F_ONE, F_ONE, F_ONE)
a.rough = F_ONE
a.mat = m4_new()
ac_next_id += 1; a.id = ac_next_id
if model != null { a.radius = f_add(f_max(model.radius, model.height), F_ONE) }
a.cull = fi(450)
if ac_actors == null { ac_actors = new []Actor }
push(ac_actors, a)
return a
}
function actor_place(a: Actor, x: int, y: int, z: int, yaw: int) -> void {
a.x = x; a.y = y; a.z = z; a.yaw = yaw
m4_trs(a.mat, x, y, z, yaw, a.scale)
}
# the scene's lighting for a lit program, once per frame
function ac_bind_scene(ap: AcProg) -> void {
if ap.scene_frame == ac_frame { return }
ap.scene_frame = ac_frame
let p = ap.prog
gl_use_program(p)
u_mat4(ap.l_view, cam_view)
u_mat4(ap.l_proj, cam_proj)
u_f(ap.l_mh, F_ZERO)
sky_bind_lighting(p)
shadow_bind(p)
fog_bind(p)
}
function ac_visible(a: Actor, shadow: bool) -> bool {
if not a.visible or a.model == null { return false }
if shadow and not a.casts { return false }
if a.cull != 0 {
let dx = f_sub(a.x, cam_pos[0]); let dz = f_sub(a.z, cam_pos[2])
let d2 = f_add(f_mul(dx, dx), f_mul(dz, dz))
var c = a.cull
if shadow { c = f_min(c, fi(300)) }
if f_gt(d2, f_mul(c, c)) { return false }
}
if not shadow and a.radius != 0 {
let r = f_mul(a.radius, a.scale)
if not cam_sphere_visible(a.x, f_add(a.y, r), a.z, f_mul(r, fl(1.5))) { return false }
}
return true
}
function actor_draw_one(a: Actor, ap: AcProg, shadow: bool) -> void {
let p = ap.prog
gl_use_program(p)
u_mat4(ap.l_model, a.mat)
var skinned = F_ZERO
if a.skin != null { skinned = F_ONE; gl_uniform_matrix4fv(ap.l_bones, a.skin.n_joints, 0, a.skin.bones) }
else if a.model.skin != null { skinned = F_ONE; gl_uniform_matrix4fv(ap.l_bones, a.model.skin.n_joints, 0, a.model.skin.bones) }
u_f(ap.l_skin, skinned)
if not shadow {
u_f(ap.l_emis, a.emissive)
u_f(ap.l_rough, a.rough)
}
gl_disable(GL_CULL_FACE)
let model = a.model
for i in 0 .. len(model.prims) {
let pr = model.prims[i]
if a.hide != null and a.hide[i] != 0 { continue }
if not shadow {
if a.ptint != null and a.ptint[i * 4] != 0 { u_f3(ap.l_tint, a.ptint[i * 4 + 1], a.ptint[i * 4 + 2], a.ptint[i * 4 + 3]) }
else { u_v3(ap.l_tint, a.tint) }
}
gl_active_texture(GL_TEXTURE0); gl_bind_texture(GL_TEXTURE_2D, pr.diff)
if not shadow {
gl_active_texture(GL_TEXTURE0 + 1); gl_bind_texture(GL_TEXTURE_2D, pr.nrm)
gl_active_texture(GL_TEXTURE0 + 2); gl_bind_texture(GL_TEXTURE_2D, pr.arm)
}
mesh_draw(pr.mesh)
}
}
function actor_draw() -> void {
if ac_actors == null { return }
ac_frame += 1
for i in 0 .. len(ac_actors) {
let a = ac_actors[i]
if not ac_visible(a, false) { continue }
var ap = ac_lit
if a.cutout { ap = ac_lit_cut }
ac_bind_scene(ap)
actor_draw_one(a, ap, false)
}
gl_enable(GL_CULL_FACE)
}
function actor_draw_casters(light_vp: words) -> void {
if ac_actors == null { return }
gl_use_program(ac_sh.prog); u_mat4(ac_sh.l_lvp, light_vp)
gl_use_program(ac_sh_cut.prog); u_mat4(ac_sh_cut.l_lvp, light_vp)
for i in 0 .. len(ac_actors) {
let a = ac_actors[i]
if not ac_visible(a, true) { continue }
var ap = ac_sh
if a.cutout { ap = ac_sh_cut }
actor_draw_one(a, ap, true)
}
}

View file

@ -0,0 +1,49 @@
hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/4k/kloofendal_48d_partly_cloudy_puresky_4k.hdr
textures/aerial_grass_rock_diff_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/aerial_grass_rock/aerial_grass_rock_diff_2k.png
textures/aerial_grass_rock_nor_gl_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/aerial_grass_rock/aerial_grass_rock_nor_gl_2k.png
textures/aerial_grass_rock_arm_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/aerial_grass_rock/aerial_grass_rock_arm_2k.png
textures/aerial_grass_rock_disp_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/aerial_grass_rock/aerial_grass_rock_disp_2k.png
textures/grass_path_2_diff_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/grass_path_2/grass_path_2_diff_2k.png
textures/grass_path_2_nor_gl_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/grass_path_2/grass_path_2_nor_gl_2k.png
textures/grass_path_2_arm_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/grass_path_2/grass_path_2_arm_2k.png
textures/forest_leaves_04_diff_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/forest_leaves_04/forest_leaves_04_diff_2k.png
textures/forest_leaves_04_nor_gl_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/forest_leaves_04/forest_leaves_04_nor_gl_2k.png
textures/forest_leaves_04_arm_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/forest_leaves_04/forest_leaves_04_arm_2k.png
textures/gray_rocks_diff_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/gray_rocks/gray_rocks_diff_2k.png
textures/gray_rocks_nor_gl_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/gray_rocks/gray_rocks_nor_gl_2k.png
textures/gray_rocks_arm_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/gray_rocks/gray_rocks_arm_2k.png
textures/snow_02_diff_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/snow_02/snow_02_diff_2k.png
textures/snow_02_nor_gl_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/snow_02/snow_02_nor_gl_2k.png
textures/snow_02_arm_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/snow_02/snow_02_arm_2k.png
models/fir_tree_01/fir_tree_01_1k.gltf https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/fir_tree_01/fir_tree_01_1k.gltf
models/fir_tree_01/fir_tree_01.bin https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/fir_tree_01/fir_tree_01.bin
models/fir_tree_01/textures/fir_tree_01_bark_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_bark_diff_1k.jpg
models/fir_tree_01/textures/fir_tree_01_bark_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_bark_nor_gl_1k.jpg
models/fir_tree_01/textures/fir_tree_01_bark_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_bark_arm_1k.jpg
models/fir_tree_01/textures/fir_tree_01_trunk_a_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_trunk_a_diff_1k.jpg
models/fir_tree_01/textures/fir_tree_01_trunk_a_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_trunk_a_nor_gl_1k.jpg
models/fir_tree_01/textures/fir_tree_01_trunk_a_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_trunk_a_arm_1k.jpg
models/fir_tree_01/textures/fir_tree_01_trunk_b_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_trunk_b_diff_1k.jpg
models/fir_tree_01/textures/fir_tree_01_trunk_b_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_trunk_b_nor_gl_1k.jpg
models/fir_tree_01/textures/fir_tree_01_trunk_b_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_trunk_b_arm_1k.jpg
models/fir_tree_01/textures/fir_tree_01_twig_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_twig_diff_1k.jpg
models/fir_tree_01/textures/fir_tree_01_twig_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_twig_nor_gl_1k.jpg
models/fir_tree_01/textures/fir_tree_01_twig_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/fir_tree_01/fir_tree_01_twig_arm_1k.jpg
models/grass_medium_01/grass_medium_01_1k.gltf https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/grass_medium_01/grass_medium_01_1k.gltf
models/grass_medium_01/grass_medium_01.bin https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/grass_medium_01/grass_medium_01.bin
models/grass_medium_01/textures/grass_medium_01_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/grass_medium_01/grass_medium_01_diff_1k.jpg
models/grass_medium_01/textures/grass_medium_01_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/grass_medium_01/grass_medium_01_nor_gl_1k.jpg
models/grass_medium_01/textures/grass_medium_01_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/grass_medium_01/grass_medium_01_arm_1k.jpg
models/boulder_01/boulder_01_1k.gltf https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/boulder_01/boulder_01_1k.gltf
models/boulder_01/boulder_01.bin https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/boulder_01/boulder_01.bin
models/boulder_01/textures/boulder_01_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/boulder_01/boulder_01_diff_1k.jpg
models/boulder_01/textures/boulder_01_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/boulder_01/boulder_01_nor_gl_1k.jpg
models/boulder_01/textures/boulder_01_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/boulder_01/boulder_01_arm_1k.jpg
models/celandine_01/celandine_01_1k.gltf https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/celandine_01/celandine_01_1k.gltf
models/celandine_01/celandine_01.bin https://dl.polyhaven.org/file/ph-assets/Models/gltf/1k/celandine_01/celandine_01.bin
models/celandine_01/textures/celandine_01_diff_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/celandine_01/celandine_01_diff_1k.jpg
models/celandine_01/textures/celandine_01_nor_gl_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/celandine_01/celandine_01_nor_gl_1k.jpg
models/celandine_01/textures/celandine_01_arm_1k.jpg https://dl.polyhaven.org/file/ph-assets/Models/jpg/1k/celandine_01/celandine_01_arm_1k.jpg
textures/cliff_side_diff_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/cliff_side/cliff_side_diff_2k.png
textures/cliff_side_nor_gl_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/cliff_side/cliff_side_nor_gl_2k.png
textures/cliff_side_arm_2k.png https://dl.polyhaven.org/file/ph-assets/Textures/png/2k/cliff_side/cliff_side_arm_2k.png

View file

@ -0,0 +1,101 @@
# ============================================================================
# camera.ludic — a first-person fly camera (yaw / pitch, metres) and its view /
# projection matrices. Float bits throughout; see fmath.ludic.
# ============================================================================
var cam_pos: words = null # x, y, z
var cam_yaw: int = 0 # radians, 0 = looking down -z
var cam_pitch: int = 0
var cam_fov: int = 0 # vertical, radians
var cam_near: int = 0
var cam_far: int = 0
var cam_aspect: int = 0
var cam_view: words = null
var cam_proj: words = null
var cam_vp: words = null
var cam_inv_vp: words = null
var cam_inv_proj: words = null
var cam_vp_clean: words = null # view-projection, kept for depth reconstruction
var cam_inv_vp_clean: words = null
var cam_fwd: words = null
var cam_right: words = null
# the view frustum's four side planes (a, b, c, d), float bits: left, right, bottom, top;
# from the clean (unjittered) view-projection, column-major m[col * 4 + row]
var cam_planes: words = null
function cam_init(aspect: int) -> void {
cam_pos = v3_new(F_ZERO, fi(2), F_ZERO)
cam_view = m4_new(); cam_proj = m4_new(); cam_vp = m4_new(); cam_inv_vp = m4_new(); cam_inv_proj = m4_new()
cam_vp_clean = m4_new(); cam_inv_vp_clean = m4_new()
cam_fwd = v3_new(F_ZERO, F_ZERO, f_neg1())
cam_right = v3_new(F_ONE, F_ZERO, F_ZERO)
cam_fov = f_rad(fi(42))
cam_near = fl(0.3)
cam_far = fi(14000)
cam_aspect = aspect
cam_update()
}
function cam_begin_frame(n: int, w: int, h: int) -> void {
cam_update()
}
function cam_set(x: int, y: int, z: int, yaw_deg: int, pitch_deg: int) -> void {
v3_set(cam_pos, x, y, z)
cam_yaw = f_rad(yaw_deg)
cam_pitch = f_rad(pitch_deg)
cam_update()
}
function cam_update() -> void {
let cy = f_cos(cam_yaw); let sy = f_sin(cam_yaw)
let cp = f_cos(cam_pitch); let sp = f_sin(cam_pitch)
v3_set(cam_fwd, f_neg(f_mul(sy, cp)), sp, f_neg(f_mul(cy, cp)))
v3_set(cam_right, cy, F_ZERO, f_neg(sy))
let at = words(3)
v3_add(at, cam_pos, cam_fwd)
let up = v3_new(F_ZERO, F_ONE, F_ZERO)
m4_look_at(cam_view, cam_pos, at, up)
m4_perspective(cam_proj, cam_fov, cam_aspect, cam_near, cam_far)
m4_mul(cam_vp_clean, cam_proj, cam_view)
m4_inverse(cam_inv_vp_clean, cam_vp_clean)
m4_mul(cam_vp, cam_proj, cam_view)
m4_inverse(cam_inv_vp, cam_vp)
m4_inverse(cam_inv_proj, cam_proj)
free(at); free(up)
cam_planes_update()
}
function cam_planes_update() -> void {
if cam_planes == null { cam_planes = words(16) }
let m = cam_vp_clean
for p in 0 .. 4 {
var r = 0
if p >= 2 { r = 1 }
var sg = F_ONE
if p == 1 or p == 3 { sg = f_neg(F_ONE) }
var a = f_add(m[3], f_mul(sg, m[r]))
var b = f_add(m[7], f_mul(sg, m[4 + r]))
var c = f_add(m[11], f_mul(sg, m[8 + r]))
var d = f_add(m[15], f_mul(sg, m[12 + r]))
let inv = f_div(F_ONE, f_sqrt(f_add(f_add(f_mul(a, a), f_mul(b, b)), f_mul(c, c))))
cam_planes[p * 4] = f_mul(a, inv); cam_planes[p * 4 + 1] = f_mul(b, inv)
cam_planes[p * 4 + 2] = f_mul(c, inv); cam_planes[p * 4 + 3] = f_mul(d, inv)
}
}
# is a sphere (float bits) at least partly inside the side planes of the view?
function cam_sphere_visible(x: int, y: int, z: int, r: int) -> bool {
if cam_planes == null { return true }
let nr = f_neg(r)
for p in 0 .. 4 {
let o = p * 4
let dist = f_add(f_add(f_add(f_mul(cam_planes[o], x), f_mul(cam_planes[o + 1], y)), f_mul(cam_planes[o + 2], z)), cam_planes[o + 3])
if f_ls(dist, nr) { return false }
}
return true
}
# fly: forward/strafe in metres, turn in radians
function cam_move(fwd: int, strafe: int, up: int, dyaw: int, dpitch: int) -> void {
cam_yaw = f_add(cam_yaw, dyaw)
cam_pitch = f_clamp(f_add(cam_pitch, dpitch), f_neg(fl(1.5)), fl(1.5))
v3_madd(cam_pos, cam_pos, cam_fwd, fwd)
v3_madd(cam_pos, cam_pos, cam_right, strafe)
cam_pos[1] = f_add(cam_pos[1], up)
cam_update()
}

View file

@ -0,0 +1,99 @@
# ============================================================================
# collide.ludic — the static colliders of the world as circles on the ground
# plane (a trunk, a boulder, a tent), sorted once into 16 m cells over the whole
# terrain. A moving thing asks col_resolve for its position pushed out of every
# circle it overlaps: three by three cells, a few dozen tests, no broad phase
# needed. Float bits, metres.
# ============================================================================
const COL_CELL: int = 16
const COL_CAP: int = 120000
var col_x: words = null
var col_z: words = null
var col_r: words = null
var col_n: int = 0
var col_side: int = 0 # cells per side
var col_start: words = null # per cell: first index into col_sorted (side*side + 1)
var col_sorted: words = null
var col_built: bool = false
var col_out: words = null # the resolved position (x, z)
function col_add(x: int, z: int, r: int) -> void {
if col_x == null { col_x = words(COL_CAP); col_z = words(COL_CAP); col_r = words(COL_CAP); col_out = words(2) }
if col_n >= COL_CAP { return }
col_x[col_n] = x; col_z[col_n] = z; col_r[col_n] = r
col_n += 1
col_built = false
}
function col_cell_of(v: int, origin: int) -> int {
var c = f_to_int(f_floor(f_div(f_add(f_sub(v, origin), fi(TERRAIN_HALF)), fi(COL_CELL))))
if c < 0 { c = 0 }
if c > col_side - 1 { c = col_side - 1 }
return c
}
function col_build() -> void {
col_side = (TERRAIN_HALF * 2) / COL_CELL
let ncell = col_side * col_side
if col_start == null { col_start = words(ncell + 1); col_sorted = words(COL_CAP) }
for i in 0 .. ncell + 1 { col_start[i] = 0 }
for i in 0 .. col_n { col_start[col_cell_of(col_z[i], ter_oz) * col_side + col_cell_of(col_x[i], ter_ox) + 1] += 1 }
for c in 0 .. ncell { col_start[c + 1] += col_start[c] }
let fill = words(ncell)
for c in 0 .. ncell { fill[c] = col_start[c] }
for i in 0 .. col_n {
let c = col_cell_of(col_z[i], ter_oz) * col_side + col_cell_of(col_x[i], ter_ox)
col_sorted[fill[c]] = i
fill[c] += 1
}
free(fill)
col_built = true
print(`colliders: {col_n}`)
}
# push (px, pz) with radius pr out of every circle it overlaps; the result is in col_out
function col_resolve(px: int, pz: int, pr: int) -> bool {
col_out[0] = px; col_out[1] = pz
if not col_built or col_n == 0 { return false }
var x = px; var z = pz
var moved = false
let cx = col_cell_of(px, ter_ox); let cz = col_cell_of(pz, ter_oz)
for pass in 0 .. 2 {
for dz in 0 .. 3 {
let zc = cz + dz - 1
if zc < 0 or zc >= col_side { continue }
for dx in 0 .. 3 {
let xc = cx + dx - 1
if xc < 0 or xc >= col_side { continue }
let c = zc * col_side + xc
for k in col_start[c] .. col_start[c + 1] {
let i = col_sorted[k]
let ex = f_sub(x, col_x[i]); let ez = f_sub(z, col_z[i])
let d2 = f_add(f_mul(ex, ex), f_mul(ez, ez))
let rr = f_add(col_r[i], pr)
if f_ls(d2, f_mul(rr, rr)) {
var d = f_sqrt(d2)
var nx = ex; var nz = ez
if f_ls(d, fl(0.001)) { d = fl(0.001); nx = F_ONE; nz = F_ZERO }
let push = f_sub(rr, d)
x = f_add(x, f_mul(f_div(nx, d), push))
z = f_add(z, f_mul(f_div(nz, d), push))
moved = true
}
}
}
}
}
col_out[0] = x; col_out[1] = z
return moved
}
# is the segment from (x0,z0) to (x1,z1) clear of every circle (a camera line of sight)?
function col_clear(x0: int, z0: int, x1: int, z1: int, r: int) -> bool {
let steps = 6
for s in 0 .. steps + 1 {
let t = fr(s, steps)
let x = f_lerp(x0, x1, t); let z = f_lerp(z0, z1, t)
if col_resolve(x, z, r) { return false }
}
return true
}

View file

@ -0,0 +1,162 @@
# ============================================================================
# daylight.ludic — a time of day over the HDRI sky. The photograph is one late
# morning; the game wants a whole day. The sun's direction and colour follow a
# simple solar arc from the time (`daylight_set`), the sky image turns to keep
# its disc under that sun, the image-based light is scaled toward a deep-blue
# night (`u_ibl_scale`), and after dusk the light becomes a moon so the world
# still has shadows and form. A campfire is the one point light (`u_fire_*`).
# The convolved sky maps are rebuilt only when the sky has turned far from the
# angle they were baked at; the height-field shadow rebakes as the sun moves.
# ============================================================================
var day_on: bool = false
var day_hours: int = 0 # float bits, 0 .. 24
var day_light: int = 0 # 0 night .. 1 full day (float bits)
var day_ibl: words = null # rgb scale on the sky's light
var day_sun_base: words = null # the HDRI's sun radiance, kept
var day_az0: int = 0 # the sun's azimuth at the reference hour (radians, yaw convention)
var day_yaw0: int = 0 # the sky yaw the scene was tuned at
var day_hour0: int = 0 # the hour the photograph was taken (10.5)
var day_dir: int = 0 # +1 / -1: which way the sun travels in yaw
var day_az: int = 0
var day_el: int = 0
var day_gen: int = 0 # bumps when the light moved enough to rebake the terrain shadow
var day_baked_az: int = 0
var day_baked_el: int = 0
var day_sky_baked: int = 0 # the sky yaw the convolutions were baked at
var fire_pos: words = null
var fire_color: words = null
var day_moon: bool = false
var hand_pos: words = null
var hand_color: words = null
var hand_dir: words = null
var hand_cone: int = 0
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)
var day_fog_base: int = 0
function daylight_init() -> void {
day_ibl = v3_new(F_ONE, F_ONE, F_ONE)
day_sun_base = v3_new(sun_color[0], sun_color[1], sun_color[2])
fire_pos = v3_new(F_ZERO, fi(-1000), F_ZERO)
fire_color = v3_new(F_ZERO, F_ZERO, 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)
day_light = F_ONE
}
# Start the clock: the scene as tuned (sky yaw `yaw0`) is the photograph's hour `hour0`;
# the sun rises and sets toward `set_yaw` (the direction it should be in at 19:00).
function daylight_start(yaw0: int, hour0: int, set_yaw: int) -> void {
day_yaw0 = yaw0; day_hour0 = hour0
day_az0 = f_atan2(f_neg(sun_dir[0]), f_neg(sun_dir[2]))
day_sky_baked = yaw0
# which way does the sun travel? the way that puts it nearest `set_yaw` at 19:00
let step = f_mul(f_sub(fl(19.0), hour0), f_rad(fi(15)))
let da = f_abs(day_wrap(f_sub(f_add(day_az0, step), set_yaw)))
let db = f_abs(day_wrap(f_sub(f_sub(day_az0, step), set_yaw)))
day_dir = F_ONE
if f_ls(db, da) { day_dir = f_neg(F_ONE) }
day_on = true
day_baked_az = fi(1000)
daylight_set(hour0)
}
function day_wrap(a: int) -> int {
let two_pi = f_mul(F_TWO, F_PI)
var d = a
while f_gt(d, F_PI) { d = f_sub(d, two_pi) }
while f_ls(d, f_neg(F_PI)) { d = f_add(d, two_pi) }
return d
}
function smoothf(a: int, b: int, x: int) -> int {
let t = f_clamp(f_div(f_sub(x, a), f_sub(b, a)), F_ZERO, F_ONE)
return f_mul(f_mul(t, t), f_sub(fi(3), f_mul(F_TWO, t)))
}
function daylight_set(hours: int) -> void {
var h = f_mod(hours, fi(24))
if f_ls(h, F_ZERO) { h = f_add(h, fi(24)) }
day_hours = h
# the arc: up at 5:30, highest (about 57 degrees) at 12:45, down at 20:00
let t = f_mul(f_div(f_sub(h, fl(5.5)), fl(14.5)), F_PI)
let el = f_rad(f_add(fi(-6), f_mul(fi(63), f_sin(t))))
let az = f_add(day_az0, f_mul(f_mul(f_sub(h, day_hour0), f_rad(fi(15))), day_dir))
day_az = az; day_el = el
let d = smoothf(f_rad(fi(-8)), f_rad(fi(12)), el)
day_light = d
# the sun, warm and dim near the horizon; past dusk, the moon from across the sky
var laz = az; var lel = f_max(el, f_rad(fi(3)))
var warm_r = F_ONE; var warm_g = F_ONE; var warm_b = F_ONE
let low = smoothf(F_ZERO, f_rad(fi(24)), el)
warm_g = f_lerp(fl(0.55), F_ONE, low); warm_b = f_lerp(fl(0.28), F_ONE, low)
# an overcast sky: the sun goes diffuse and grey, a lightning flash brings it back white
let oc = f_clamp(day_overcast, F_ZERO, F_ONE)
let sunk = f_add(f_sub(F_ONE, f_mul(fl(0.92), oc)), f_mul(fl(2.5), day_flash))
var sr = f_mul(day_sun_base[0], f_mul(f_mul(d, warm_r), sunk))
var sg = f_mul(day_sun_base[1], f_mul(f_mul(d, warm_g), sunk))
var sb = f_mul(day_sun_base[2], f_mul(f_mul(d, warm_b), sunk))
day_moon = false
if f_ls(el, f_rad(fi(-7))) {
day_moon = true
laz = f_add(az, F_PI)
lel = f_clamp(f_neg(el), f_rad(fi(10)), f_rad(fi(55)))
let m = smoothf(f_rad(fi(-7)), f_rad(fi(-16)), el)
sr = f_mul(day_sun_base[0], f_mul(fl(0.0045), m))
sg = f_mul(day_sun_base[1], f_mul(fl(0.0060), m))
sb = f_mul(day_sun_base[2], f_mul(fl(0.0095), m))
}
let ce = f_cos(lel)
v3_set(sun_dir, f_neg(f_mul(f_sin(laz), ce)), f_sin(lel), f_neg(f_mul(f_cos(laz), ce)))
v3_set(sun_color, sr, sg, sb)
# the sky's light: full by day, a deep blue by night, amber through the dusk
let dusk = f_mul(smoothf(f_rad(fi(-10)), f_rad(fi(2)), el), f_sub(F_ONE, smoothf(f_rad(fi(2)), f_rad(fi(18)), el)))
v3_set(day_ibl, f_lerp(fl(0.020), F_ONE, d), f_lerp(fl(0.026), F_ONE, d), f_lerp(fl(0.045), F_ONE, d))
day_ibl[0] = f_mul(day_ibl[0], f_add(F_ONE, f_mul(fl(0.35), dusk)))
day_ibl[2] = f_mul(day_ibl[2], f_sub(F_ONE, f_mul(fl(0.25), dusk)))
# clouds: less light, and greyer (the blue and the warmth both fade)
let grey = f_add(f_mul(f_add(day_ibl[0], f_add(day_ibl[1], day_ibl[2])), fl(0.3333)), f_mul(fl(0.6), day_flash))
let dim = f_sub(F_ONE, f_mul(fl(0.55), oc))
for i in 0 .. 3 { day_ibl[i] = f_mul(f_lerp(day_ibl[i], grey, f_mul(fl(0.7), oc)), dim) }
if day_fog_base == 0 { day_fog_base = r3d_fog_density }
r3d_fog_density = f_mul(day_fog_base, day_fog_mul)
# exposure: auto-exposure must not turn the night into day
post_exposure_max = f_lerp(fl(4.5), fi(20), d)
# the visible sky turns with the sun (cheap); its convolutions rebake when far off
let sky_yaw_now = f_add(day_yaw0, f_mul(f_mul(f_sub(h, day_hour0), f_rad(fi(15))), day_dir))
sky_set_rot(sky_yaw_now)
if f_gt(f_abs(day_wrap(f_sub(sky_yaw_now, day_sky_baked))), f_rad(fi(35))) and f_gt(d, fl(0.05)) {
day_sky_baked = sky_yaw_now
sky_precompute()
}
# the terrain's baked shadow follows the light in steps
if f_gt(f_abs(day_wrap(f_sub(laz, day_baked_az))), f_rad(fi(4))) or f_gt(f_abs(f_sub(lel, day_baked_el)), f_rad(fi(3))) {
day_baked_az = laz; day_baked_el = lel
day_gen += 1
}
}
# the weather over the valley: overcast 0..1, a fog multiplier, a lightning flash 0..1
function daylight_weather(overcast: int, fog_mul: int, flash: int) -> void {
day_overcast = overcast; day_fog_mul = fog_mul; day_flash = flash
}
# the campfire: a point light at (x, y, z) of `strength` (0 = out)
function daylight_fire(x: int, y: int, z: int, strength: int) -> void {
v3_set(fire_pos, x, y, z)
v3_set(fire_color, f_mul(fl(9.0), strength), f_mul(fl(4.6), strength), f_mul(fl(1.4), strength))
}
# 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 {
v3_set(hand_pos, x, y, z); v3_set(hand_dir, dx, dy, dz); hand_cone = cone
v3_set(hand_color, r, g, b)
}
function daylight_bind(prog: int) -> void {
u_v3(gl_uniform(prog, "u_hand_pos"), hand_pos)
u_v3(gl_uniform(prog, "u_hand_color"), hand_color)
u_v3(gl_uniform(prog, "u_hand_dir"), hand_dir)
u_f(gl_uniform(prog, "u_hand_cone"), hand_cone)
u_v3(gl_uniform(prog, "u_ibl_scale"), day_ibl)
u_f(gl_uniform(prog, "u_daylight"), day_light)
u_v3(gl_uniform(prog, "u_fire_pos"), fire_pos)
u_v3(gl_uniform(prog, "u_fire_color"), fire_color)
}

View file

@ -0,0 +1,206 @@
# ============================================================================
# fmath.ludic — IEEE single-precision math for the renderer.
#
# Ludic's own numbers are Q16.16; a renderer wants the floats the GPU eats. A
# float lives here as its bit pattern in an `int`, arithmetic goes through the
# f_* helpers Gl.* provides (gl.ll), and vectors / matrices are `words` buffers
# of those bit patterns — which is exactly the memory layout glUniform* and
# glBufferData expect, so nothing is converted at upload.
#
# fl(x) a fixed literal as a float fr(n, d) n/d as a float
# v3_* 3-vectors at an index of a words buffer (x, y, z consecutive)
# m4_* 4x4 column-major matrices in a 16-word buffer
# ============================================================================
const F_ZERO: int = 0x00000000
const F_ONE: int = 0x3F800000
const F_TWO: int = 0x40000000
const F_HALF: int = 0x3F000000
const F_PI: int = 0x40490FDB
function fl(x: fixed) -> int { return fx_to_f32(x) }
function fi(n: int) -> int { return f_from_int(n) }
function fr(n: int, d: int) -> int { return f_div(f_from_int(n), f_from_int(d)) }
function f_neg1() -> int { return f_neg(F_ONE) }
function f_clamp(x: int, lo: int, hi: int) -> int { return f_min(f_max(x, lo), hi) }
function f_lerp(a: int, b: int, t: int) -> int { return f_add(a, f_mul(f_sub(b, a), t)) }
function f_gt(a: int, b: int) -> bool { return f_lt(b, a) != 0 }
function f_ls(a: int, b: int) -> bool { return f_lt(a, b) != 0 }
function f_rad(deg: int) -> int { return f_mul(deg, f_div(F_PI, fi(180))) }
function f_fx(x: int) -> fixed { return f32_to_fx(x) }
# ---- vectors ----------------------------------------------------------------
function v3_new(x: int, y: int, z: int) -> words {
let v = words(3)
v[0] = x
v[1] = y
v[2] = z
return v
}
function v3_set(v: words, x: int, y: int, z: int) -> void { v[0] = x; v[1] = y; v[2] = z }
function v3_copy(o: words, a: words) -> void { o[0] = a[0]; o[1] = a[1]; o[2] = a[2] }
function v3_add(o: words, a: words, b: words) -> void {
o[0] = f_add(a[0], b[0]); o[1] = f_add(a[1], b[1]); o[2] = f_add(a[2], b[2])
}
function v3_sub(o: words, a: words, b: words) -> void {
o[0] = f_sub(a[0], b[0]); o[1] = f_sub(a[1], b[1]); o[2] = f_sub(a[2], b[2])
}
function v3_scale(o: words, a: words, s: int) -> void {
o[0] = f_mul(a[0], s); o[1] = f_mul(a[1], s); o[2] = f_mul(a[2], s)
}
function v3_madd(o: words, a: words, b: words, s: int) -> void { # o = a + b*s
o[0] = f_add(a[0], f_mul(b[0], s)); o[1] = f_add(a[1], f_mul(b[1], s)); o[2] = f_add(a[2], f_mul(b[2], s))
}
function v3_dot(a: words, b: words) -> int {
return f_add(f_add(f_mul(a[0], b[0]), f_mul(a[1], b[1])), f_mul(a[2], b[2]))
}
function v3_cross(o: words, a: words, b: words) -> void {
let x = f_sub(f_mul(a[1], b[2]), f_mul(a[2], b[1]))
let y = f_sub(f_mul(a[2], b[0]), f_mul(a[0], b[2]))
let z = f_sub(f_mul(a[0], b[1]), f_mul(a[1], b[0]))
o[0] = x; o[1] = y; o[2] = z
}
function v3_len(a: words) -> int { return f_sqrt(v3_dot(a, a)) }
function v3_normalize(o: words, a: words) -> void {
let l = v3_len(a)
if l == 0 { o[0] = 0; o[1] = 0; o[2] = 0; return }
let inv = f_div(F_ONE, l)
v3_scale(o, a, inv)
}
function v3_dist(a: words, b: words) -> int {
let t = words(3)
v3_sub(t, a, b)
let d = v3_len(t)
free(t)
return d
}
# ---- matrices (column-major, m[col*4 + row]) ------------------------------------
function m4_new() -> words { let m = words(16); m4_identity(m); return m }
function m4_identity(m: words) -> void {
for i in 0 .. 16 { m[i] = F_ZERO }
m[0] = F_ONE; m[5] = F_ONE; m[10] = F_ONE; m[15] = F_ONE
}
function m4_copy(o: words, a: words) -> void { for i in 0 .. 16 { o[i] = a[i] } }
# o = a * b (o may not alias a or b)
function m4_mul(o: words, a: words, b: words) -> void {
for c in 0 .. 4 {
for r in 0 .. 4 {
var s = F_ZERO
for k in 0 .. 4 { s = f_add(s, f_mul(a[k * 4 + r], b[c * 4 + k])) }
o[c * 4 + r] = s
}
}
}
function m4_mul_into(a: words, b: words) -> void { # a = a * b
let t = words(16)
m4_mul(t, a, b)
m4_copy(a, t)
free(t)
}
function m4_translation(m: words, x: int, y: int, z: int) -> void {
m4_identity(m)
m[12] = x; m[13] = y; m[14] = z
}
function m4_scaling(m: words, x: int, y: int, z: int) -> void {
m4_identity(m)
m[0] = x; m[5] = y; m[10] = z
}
function m4_rotation_y(m: words, angle: int) -> void {
m4_identity(m)
let c = f_cos(angle); let s = f_sin(angle)
m[0] = c; m[2] = f_neg(s); m[8] = s; m[10] = c
}
function m4_rotation_x(m: words, angle: int) -> void {
m4_identity(m)
let c = f_cos(angle); let s = f_sin(angle)
m[5] = c; m[6] = s; m[9] = f_neg(s); m[10] = c
}
function m4_rotation_z(m: words, angle: int) -> void {
m4_identity(m)
let c = f_cos(angle); let s = f_sin(angle)
m[0] = c; m[1] = s; m[4] = f_neg(s); m[5] = c
}
# a model matrix: translate * rotate_y * uniform scale
function m4_trs(m: words, x: int, y: int, z: int, yaw: int, s: int) -> void {
m4_rotation_y(m, yaw)
for i in 0 .. 12 { m[i] = f_mul(m[i], s) }
m[12] = x; m[13] = y; m[14] = z
}
# OpenGL clip space (z in [-1, 1]); fovy in radians
function m4_perspective(m: words, fovy: int, aspect: int, near: int, far: int) -> void {
for i in 0 .. 16 { m[i] = F_ZERO }
let f = f_div(F_ONE, f_tan(f_mul(fovy, F_HALF)))
m[0] = f_div(f, aspect)
m[5] = f
m[10] = f_div(f_add(far, near), f_sub(near, far))
m[11] = f_neg(F_ONE)
m[14] = f_div(f_mul(f_mul(F_TWO, far), near), f_sub(near, far))
}
function m4_ortho(m: words, l: int, r: int, b: int, t: int, n: int, f: int) -> void {
m4_identity(m)
m[0] = f_div(F_TWO, f_sub(r, l))
m[5] = f_div(F_TWO, f_sub(t, b))
m[10] = f_div(f_neg(F_TWO), f_sub(f, n))
m[12] = f_neg(f_div(f_add(r, l), f_sub(r, l)))
m[13] = f_neg(f_div(f_add(t, b), f_sub(t, b)))
m[14] = f_neg(f_div(f_add(f, n), f_sub(f, n)))
}
function m4_look_at(m: words, eye: words, at: words, up: words) -> void {
let fwd = words(3); let side = words(3); let u = words(3); let t = words(3)
v3_sub(t, at, eye)
v3_normalize(fwd, t)
v3_cross(t, fwd, up)
v3_normalize(side, t)
v3_cross(u, side, fwd)
m4_identity(m)
m[0] = side[0]; m[4] = side[1]; m[8] = side[2]
m[1] = u[0]; m[5] = u[1]; m[9] = u[2]
m[2] = f_neg(fwd[0]); m[6] = f_neg(fwd[1]); m[10] = f_neg(fwd[2])
m[12] = f_neg(v3_dot(side, eye))
m[13] = f_neg(v3_dot(u, eye))
m[14] = v3_dot(fwd, eye)
free(fwd); free(side); free(u); free(t)
}
# general 4x4 inverse (cofactor expansion); o may not alias a
function m4_inverse(o: words, a: words) -> bool {
let inv = words(16)
inv[0] = f_add(f_sub(f_add(f_mul(a[5], f_mul(a[10], a[15])), f_neg(f_mul(a[5], f_mul(a[11], a[14])))), f_mul(a[9], f_mul(a[6], a[15]))), f_add(f_mul(a[9], f_mul(a[7], a[14])), f_sub(f_mul(a[13], f_mul(a[6], a[11])), f_mul(a[13], f_mul(a[7], a[10])))))
inv[4] = f_add(f_sub(f_add(f_neg(f_mul(a[4], f_mul(a[10], a[15]))), f_mul(a[4], f_mul(a[11], a[14]))), f_mul(a[8], f_mul(a[7], a[14]))), f_add(f_mul(a[8], f_mul(a[6], a[15])), f_sub(f_mul(a[12], f_mul(a[7], a[10])), f_mul(a[12], f_mul(a[6], a[11])))))
inv[8] = f_add(f_sub(f_add(f_mul(a[4], f_mul(a[9], a[15])), f_neg(f_mul(a[4], f_mul(a[11], a[13])))), f_mul(a[8], f_mul(a[5], a[15]))), f_add(f_mul(a[8], f_mul(a[7], a[13])), f_sub(f_mul(a[12], f_mul(a[5], a[11])), f_mul(a[12], f_mul(a[7], a[9])))))
inv[12] = f_add(f_sub(f_add(f_neg(f_mul(a[4], f_mul(a[9], a[14]))), f_mul(a[4], f_mul(a[10], a[13]))), f_mul(a[8], f_mul(a[6], a[13]))), f_add(f_mul(a[8], f_mul(a[5], a[14])), f_sub(f_mul(a[12], f_mul(a[6], a[9])), f_mul(a[12], f_mul(a[5], a[10])))))
inv[1] = f_add(f_sub(f_add(f_neg(f_mul(a[1], f_mul(a[10], a[15]))), f_mul(a[1], f_mul(a[11], a[14]))), f_mul(a[9], f_mul(a[3], a[14]))), f_add(f_mul(a[9], f_mul(a[2], a[15])), f_sub(f_mul(a[13], f_mul(a[3], a[10])), f_mul(a[13], f_mul(a[2], a[11])))))
inv[5] = f_add(f_sub(f_add(f_mul(a[0], f_mul(a[10], a[15])), f_neg(f_mul(a[0], f_mul(a[11], a[14])))), f_mul(a[8], f_mul(a[2], a[15]))), f_add(f_mul(a[8], f_mul(a[3], a[14])), f_sub(f_mul(a[12], f_mul(a[2], a[11])), f_mul(a[12], f_mul(a[3], a[10])))))
inv[9] = f_add(f_sub(f_add(f_neg(f_mul(a[0], f_mul(a[9], a[15]))), f_mul(a[0], f_mul(a[11], a[13]))), f_mul(a[8], f_mul(a[3], a[13]))), f_add(f_mul(a[8], f_mul(a[1], a[15])), f_sub(f_mul(a[12], f_mul(a[3], a[9])), f_mul(a[12], f_mul(a[1], a[11])))))
inv[13] = f_add(f_sub(f_add(f_mul(a[0], f_mul(a[9], a[14])), f_neg(f_mul(a[0], f_mul(a[10], a[13])))), f_mul(a[8], f_mul(a[1], a[14]))), f_add(f_mul(a[8], f_mul(a[2], a[13])), f_sub(f_mul(a[12], f_mul(a[1], a[10])), f_mul(a[12], f_mul(a[2], a[9])))))
inv[2] = f_add(f_sub(f_add(f_mul(a[1], f_mul(a[6], a[15])), f_neg(f_mul(a[1], f_mul(a[7], a[14])))), f_mul(a[5], f_mul(a[2], a[15]))), f_add(f_mul(a[5], f_mul(a[3], a[14])), f_sub(f_mul(a[13], f_mul(a[2], a[7])), f_mul(a[13], f_mul(a[3], a[6])))))
inv[6] = f_add(f_sub(f_add(f_neg(f_mul(a[0], f_mul(a[6], a[15]))), f_mul(a[0], f_mul(a[7], a[14]))), f_mul(a[4], f_mul(a[3], a[14]))), f_add(f_mul(a[4], f_mul(a[2], a[15])), f_sub(f_mul(a[12], f_mul(a[3], a[6])), f_mul(a[12], f_mul(a[2], a[7])))))
inv[10] = f_add(f_sub(f_add(f_mul(a[0], f_mul(a[5], a[15])), f_neg(f_mul(a[0], f_mul(a[7], a[13])))), f_mul(a[4], f_mul(a[1], a[15]))), f_add(f_mul(a[4], f_mul(a[3], a[13])), f_sub(f_mul(a[12], f_mul(a[1], a[7])), f_mul(a[12], f_mul(a[3], a[5])))))
inv[14] = f_add(f_sub(f_add(f_neg(f_mul(a[0], f_mul(a[5], a[14]))), f_mul(a[0], f_mul(a[6], a[13]))), f_mul(a[4], f_mul(a[2], a[13]))), f_add(f_mul(a[4], f_mul(a[1], a[14])), f_sub(f_mul(a[12], f_mul(a[2], a[5])), f_mul(a[12], f_mul(a[1], a[6])))))
inv[3] = f_add(f_sub(f_add(f_neg(f_mul(a[1], f_mul(a[6], a[11]))), f_mul(a[1], f_mul(a[7], a[10]))), f_mul(a[5], f_mul(a[3], a[10]))), f_add(f_mul(a[5], f_mul(a[2], a[11])), f_sub(f_mul(a[9], f_mul(a[3], a[6])), f_mul(a[9], f_mul(a[2], a[7])))))
inv[7] = f_add(f_sub(f_add(f_mul(a[0], f_mul(a[6], a[11])), f_neg(f_mul(a[0], f_mul(a[7], a[10])))), f_mul(a[4], f_mul(a[2], a[11]))), f_add(f_mul(a[4], f_mul(a[3], a[10])), f_sub(f_mul(a[8], f_mul(a[2], a[7])), f_mul(a[8], f_mul(a[3], a[6])))))
inv[11] = f_add(f_sub(f_add(f_neg(f_mul(a[0], f_mul(a[5], a[11]))), f_mul(a[0], f_mul(a[7], a[9]))), f_mul(a[4], f_mul(a[3], a[9]))), f_add(f_mul(a[4], f_mul(a[1], a[11])), f_sub(f_mul(a[8], f_mul(a[3], a[5])), f_mul(a[8], f_mul(a[1], a[7])))))
inv[15] = f_add(f_sub(f_add(f_mul(a[0], f_mul(a[5], a[10])), f_neg(f_mul(a[0], f_mul(a[6], a[9])))), f_mul(a[4], f_mul(a[1], a[10]))), f_add(f_mul(a[4], f_mul(a[2], a[9])), f_sub(f_mul(a[8], f_mul(a[1], a[6])), f_mul(a[8], f_mul(a[2], a[5])))))
let det = f_add(f_add(f_mul(a[0], inv[0]), f_mul(a[1], inv[4])), f_add(f_mul(a[2], inv[8]), f_mul(a[3], inv[12])))
if det == 0 { free(inv); return false }
let id = f_div(F_ONE, det)
for i in 0 .. 16 { o[i] = f_mul(inv[i], id) }
free(inv)
return true
}
# transform a point (w = 1) by m: o = m * (x, y, z, 1), returns w
function m4_xform_point(o: words, m: words, x: int, y: int, z: int) -> int {
o[0] = f_add(f_add(f_mul(m[0], x), f_mul(m[4], y)), f_add(f_mul(m[8], z), m[12]))
o[1] = f_add(f_add(f_mul(m[1], x), f_mul(m[5], y)), f_add(f_mul(m[9], z), m[13]))
o[2] = f_add(f_add(f_mul(m[2], x), f_mul(m[6], y)), f_add(f_mul(m[10], z), m[14]))
return f_add(f_add(f_mul(m[3], x), f_mul(m[7], y)), f_add(f_mul(m[11], z), m[15]))
}
# ---- uniforms ------------------------------------------------------------------
function u_mat4(loc: int, m: words) -> void { gl_uniform_matrix4fv(loc, 1, 0, m) }
function u_f(loc: int, v: int) -> void { let t = gl_scratch(); t[0] = v; gl_uniform1fv(loc, 1, t) }
function u_f2(loc: int, x: int, y: int) -> void { let t = gl_scratch(); t[0] = x; t[1] = y; gl_uniform2fv(loc, 1, t) }
function u_f3(loc: int, x: int, y: int, z: int) -> void { let t = gl_scratch(); t[0] = x; t[1] = y; t[2] = z; gl_uniform3fv(loc, 1, t) }
function u_f4(loc: int, x: int, y: int, z: int, w: int) -> void { let t = gl_scratch(); t[0] = x; t[1] = y; t[2] = z; t[3] = w; gl_uniform4fv(loc, 1, t) }
function u_v3(loc: int, v: words) -> void { gl_uniform3fv(loc, 1, v) }
function u_i(loc: int, v: int) -> void { gl_uniform1i(loc, v) }

View file

@ -0,0 +1,229 @@
# ============================================================================
# gltf.ludic — a glTF 2.0 loader for scanned models: one named node's mesh,
# each primitive uploaded straight from the .bin (positions, normals, uvs,
# indices) with its baseColor / normal / ARM textures. Only the byte ranges the
# node needs are read, so a 500 MB scan library costs what one tree costs.
# ============================================================================
property Prim {
mesh: Mesh,
diff: int = 0,
nrm: int = 0,
arm: int = 0,
name: string # the material's name (a part to tint: "hk_jacket")
}
property Model {
prims: []Prim,
radius: int = 0, # float bits: max horizontal extent from the origin
height: int = 0, # float bits: y extent above ymin
ymin: int = 0,
tris: int = 0,
skin: Skin # the skeleton, for a skinned node (skin.ludic); null for a rigid model
}
var gltf_dir: string = null
var gltf_bin: pointer = null
var gltf_doc: Val = null
var gltf_count: int = 0
var gltf_ctype: int = 0
var gltf_comps: int = 0
var gltf_tex_paths: []pointer = null
var gltf_tex_ids: words = null
var gltf_tex_n: int = 0
var gltf_white: int = 0
var gltf_flat: int = 0
# a JSON number as float bits (ints and fixed-point decimals both)
function jnum(v: Val) -> int {
if v.tag == 2 { return fx_to_f32(v.num) }
return f_from_int(v.num)
}
function jint(v: Val, key: pointer, fallback: int) -> int {
if value_has(v, key) == 0 { return fallback }
return value_as_int(value_get(v, key))
}
# Materials seen so far, by name: a LOD chain exported from the .blend (tools/glgen/
# lod_export.py) carries the material names but no images — the blend links textures
# that are not downloaded with it — so its primitives take the textures the LOD0
# download's material of the same name loaded.
var gltf_mat_names: []string = null
var gltf_mat_diff: words = null
var gltf_mat_nrm: words = null
var gltf_mat_arm: words = null
var gltf_mat_n: int = 0
function gltf_mat_find(name: string) -> int {
if gltf_mat_names == null { return -1 }
for i in 0 .. gltf_mat_n { if gltf_mat_names[i] == name { return i } }
return -1
}
function gltf_mat_remember(name: string, diff: int, nrm: int, arm: int) -> void {
if gltf_mat_names == null { gltf_mat_names = new []string; gltf_mat_diff = words(256); gltf_mat_nrm = words(256); gltf_mat_arm = words(256) }
if gltf_mat_find(name) >= 0 or gltf_mat_n >= 256 { return }
push(gltf_mat_names, name); gltf_mat_diff[gltf_mat_n] = diff; gltf_mat_nrm[gltf_mat_n] = nrm; gltf_mat_arm[gltf_mat_n] = arm
gltf_mat_n += 1
}
var gltf_cutout: bool = false # the material being loaded is alpha-blended (a cut-out atlas)
function gltf_texture(uri: pointer, srgb: bool) -> int {
if gltf_tex_paths == null { gltf_tex_paths = new []pointer; gltf_tex_ids = words(256) }
var png: string = uri
let n = len(uri)
if n > 4 and uri[n - 4] == '.' and uri[n - 3] == 'j' { png = uri[0 .. n - 4] + ".png" }
let path = gltf_dir + "/" + png
var i = 0
while i < gltf_tex_n { if gltf_tex_paths[i] == path { return gltf_tex_ids[i] }; i += 1 }
var dil = 0
if gltf_cutout { dil = 24 }
let id = tex_load_ex(path, srgb, dil)
if gltf_tex_n < 256 { push(gltf_tex_paths, path); gltf_tex_ids[gltf_tex_n] = id; gltf_tex_n += 1 }
return id
}
# the image uri behind materials[m].<slot>.index, or null
function gltf_mat_uri(mat: Val, slot: pointer) -> pointer {
var holder = mat
if slot == "baseColorTexture" or slot == "metallicRoughnessTexture" {
if value_has(mat, "pbrMetallicRoughness") == 0 { return null }
holder = value_get(mat, "pbrMetallicRoughness")
}
if value_has(holder, slot) == 0 { return null }
let ti = value_as_int(value_get(value_get(holder, slot), "index"))
let tex = value_at(value_get(gltf_doc, "textures"), ti)
let src = value_as_int(value_get(tex, "source"))
let img = value_at(value_get(gltf_doc, "images"), src)
return value_as_str(value_get(img, "uri"))
}
# read one accessor's bytes from the .bin; sets gltf_count / gltf_ctype / gltf_comps
function gltf_accessor(idx: int) -> pointer {
let acc = value_at(value_get(gltf_doc, "accessors"), idx)
let bv = value_at(value_get(gltf_doc, "bufferViews"), jint(acc, "bufferView", 0))
let off = jint(bv, "byteOffset", 0) + jint(acc, "byteOffset", 0)
gltf_count = jint(acc, "count", 0)
gltf_ctype = jint(acc, "componentType", 5126)
let ty = value_as_str(value_get(acc, "type"))
gltf_comps = 1
if ty == "VEC2" { gltf_comps = 2 }
if ty == "VEC3" { gltf_comps = 3 }
if ty == "VEC4" { gltf_comps = 4 }
if ty == "MAT2" { gltf_comps = 4 }
if ty == "MAT3" { gltf_comps = 9 }
if ty == "MAT4" { gltf_comps = 16 } # a skin's inverse bind matrices
var csz = 4
if gltf_ctype == 5123 or gltf_ctype == 5122 { csz = 2 }
if gltf_ctype == 5121 or gltf_ctype == 5120 { csz = 1 }
let n = gltf_count * gltf_comps * csz
let buf = bytes(n + 8)
file_seek(gltf_bin, off, 0)
file_read(gltf_bin, buf, n)
return buf
}
function gltf_attrib(m: Mesh, attrs: Val, name: pointer, loc: int) -> bool {
if value_has(attrs, name) == 0 { return false }
let data = gltf_accessor(value_as_int(value_get(attrs, name)))
let b = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, b)
gl_buffer_data(GL_ARRAY_BUFFER, gltf_count * gltf_comps * 4, data, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(loc)
gl_vertex_attrib_pointer(loc, gltf_comps, GL_FLOAT, 0, 0, null)
free(data)
if loc == 0 { m.vbo = b }
return true
}
function gltf_prim(p: Val) -> Prim {
let pr = new Prim
let m = new Mesh
m.vao = gl_vao()
let attrs = value_get(p, "attributes")
gltf_attrib(m, attrs, "POSITION", 0)
gltf_attrib(m, attrs, "NORMAL", 1)
gltf_attrib(m, attrs, "TEXCOORD_0", 2)
skin_attribs(m, attrs) # JOINTS_0 / WEIGHTS_0 onto 5 / 6, when the mesh has them
let idx = gltf_accessor(value_as_int(value_get(p, "indices")))
var isz = 4
m.itype = GL_UNSIGNED_INT
if gltf_ctype == 5123 { isz = 2; m.itype = GL_UNSIGNED_SHORT }
m.ebo = gl_buffer()
gl_bind_buffer(GL_ELEMENT_ARRAY_BUFFER, m.ebo)
gl_buffer_data(GL_ELEMENT_ARRAY_BUFFER, gltf_count * isz, idx, GL_STATIC_DRAW)
free(idx)
m.count = gltf_count
gl_bind_vertex_array(0)
pr.mesh = m
# material textures
if gltf_white == 0 { gltf_white = tex_solid(200, 200, 200, 255); gltf_flat = tex_solid(128, 128, 255, 255) }
pr.diff = gltf_white; pr.nrm = gltf_flat; pr.arm = gltf_white
if value_has(p, "material") != 0 {
let mat = value_at(value_get(gltf_doc, "materials"), value_as_int(value_get(p, "material")))
gltf_cutout = value_has(mat, "alphaMode") != 0
let ud = gltf_mat_uri(mat, "baseColorTexture")
let un = gltf_mat_uri(mat, "normalTexture")
let ua = gltf_mat_uri(mat, "metallicRoughnessTexture")
if ud != null { pr.diff = gltf_texture(ud, true) }
if un != null { pr.nrm = gltf_texture(un, false) }
if ua != null { pr.arm = gltf_texture(ua, false) }
var mname: string = null
if value_has(mat, "name") != 0 { mname = value_as_str(value_get(mat, "name")) }
pr.name = mname
if mname != null {
if ud != null { gltf_mat_remember(mname, pr.diff, pr.nrm, pr.arm) }
else {
let k = gltf_mat_find(mname)
if k >= 0 { pr.diff = gltf_mat_diff[k]; pr.nrm = gltf_mat_nrm[k]; pr.arm = gltf_mat_arm[k] }
else { print(`gltf: material {mname} has no textures and none were loaded before it`) }
}
}
}
return pr
}
# Load the mesh of the node called `node_name` from dir/file.
function gltf_load(dir: string, file: string, node_name: string) -> Model {
gltf_dir = dir
let text = Fs.read_text(dir + "/" + file)
if text == null { print(`gltf: cannot read {dir}/{file}`); return null }
gltf_doc = Json.parse(text)
let buffers = value_get(gltf_doc, "buffers")
let bin_uri = value_as_str(value_get(value_at(buffers, 0), "uri"))
gltf_bin = file_open(dir + "/" + bin_uri, "rb")
if gltf_bin == null { print(`gltf: cannot open {bin_uri}`); return null }
let nodes = value_get(gltf_doc, "nodes")
var mesh_idx = -1
var skin_idx = -1
for i in 0 .. value_count(nodes) {
let nd = value_at(nodes, i)
if value_as_str(value_get(nd, "name")) == node_name { mesh_idx = jint(nd, "mesh", -1); skin_idx = jint(nd, "skin", -1) }
}
if mesh_idx < 0 { print(`gltf: no node {node_name} in {file}`); file_close(gltf_bin); return null }
let model = new Model
model.prims = new []Prim
let mesh = value_at(value_get(gltf_doc, "meshes"), mesh_idx)
let prims = value_get(mesh, "primitives")
var r2 = F_ZERO; var ymin = fi(1000); var ymax = fi(-1000)
for i in 0 .. value_count(prims) {
let p = value_at(prims, i)
push(model.prims, gltf_prim(p))
model.tris += gltf_count / 3
# bounds from the accessor min/max
let acc = value_at(value_get(gltf_doc, "accessors"), value_as_int(value_get(value_get(p, "attributes"), "POSITION")))
let mn = value_get(acc, "min"); let mx = value_get(acc, "max")
let x0 = f_abs(jnum(value_at(mn, 0))); let x1 = f_abs(jnum(value_at(mx, 0)))
let z0 = f_abs(jnum(value_at(mn, 2))); let z1 = f_abs(jnum(value_at(mx, 2)))
let rx = f_max(x0, x1); let rz = f_max(z0, z1)
let rr = f_add(f_mul(rx, rx), f_mul(rz, rz))
if f_gt(rr, r2) { r2 = rr }
let y0 = jnum(value_at(mn, 1)); let y1 = jnum(value_at(mx, 1))
if f_ls(y0, ymin) { ymin = y0 }
if f_gt(y1, ymax) { ymax = y1 }
}
if skin_idx >= 0 { model.skin = skin_load(skin_idx) }
file_close(gltf_bin)
model.radius = f_sqrt(r2)
model.ymin = ymin
model.height = f_sub(ymax, ymin)
print(`gltf: {node_name}: {len(model.prims)} prims, {model.tris} tris`)
return model
}

View file

@ -0,0 +1,161 @@
# ============================================================================
# grass.ludic — procedural GPU ground cover with continuous density (no rings).
#
# The world is cut into 16 m cells; blade j of a cell always stands in the same place
# (shaders/grass.vert). Draws are per tile: the CPU walks tiles around the camera,
# frustum-culls them, and feeds each visible tile as many blade indices per cell as its
# NEAREST point could need; the vertex stage then keeps only the indices that exist at
# each blade's own distance, so density is one smooth function of distance everywhere.
# Tiles are 16 m near, 64 m in the middle distance and 256 m far, purely to keep the
# draw count down — the cells and their hashes are the same in every tile size.
# ============================================================================
const GRASS_CELL: int = 16
var grass_prog: int = 0
var grass_mesh: Mesh = null
var grass_on: bool = true
var grass_wind: int = 0
var grass_s0: int = 0 # float bits: blade spacing at the camera (m)
var grass_d0: int = 0 # the distance at which the spacing has doubled (m)
var grass_radius: int = 0 # no blades past this (m)
var grass_draws: int = 0
var grass_dbg: int = 0
# a blade: `rows` rows of 2 vertices (x across, y along, z bend), attribute 2 = uv
function grass_blade_mesh(rows: int) -> Mesh {
let m = new Mesh
m.vao = gl_vao()
let v = gl_floats(rows * 2 * 5)
var k = 0
for r in 0 .. rows {
let t = fr(r, rows - 1)
let taper = f_max(f_sub(F_ONE, f_mul(t, f_mul(t, f_sqrt(t)))), fl(0.12))
let bend = f_mul(f_mul(t, t), fl(0.28))
for sd in 0 .. 2 {
var x = f_neg(F_HALF)
if sd == 1 { x = F_HALF }
gl_put_bits(v, k, f_mul(x, taper)); gl_put_bits(v, k + 1, t); gl_put_bits(v, k + 2, bend)
gl_put_bits(v, k + 3, fi(sd)); gl_put_bits(v, k + 4, t)
k += 5
}
}
m.vbo = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, m.vbo)
gl_buffer_data(GL_ARRAY_BUFFER, gl_bytes_of(rows * 2 * 5), v, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(0); gl_vertex_attrib_pointer(0, 3, GL_FLOAT, 0, 20, null)
gl_enable_vertex_attrib_array(2); gl_vertex_attrib_pointer(2, 2, GL_FLOAT, 0, 20, gl_ptr(null, 12))
free(v)
let nq = rows - 1
let idx = words(nq * 6)
for q in 0 .. nq {
let b = q * 2
idx[q * 6] = b; idx[q * 6 + 1] = b + 1; idx[q * 6 + 2] = b + 2
idx[q * 6 + 3] = b + 1; idx[q * 6 + 4] = b + 3; idx[q * 6 + 5] = b + 2
}
m.ebo = gl_buffer()
gl_bind_buffer(GL_ELEMENT_ARRAY_BUFFER, m.ebo)
gl_buffer_data(GL_ELEMENT_ARRAY_BUFFER, nq * 6 * 4, idx, GL_STATIC_DRAW)
free(idx)
m.count = nq * 6
gl_bind_vertex_array(0)
return m
}
function grass_init() -> void {
grass_prog = r3d_program("grass.vert", "model.frag", "#define FOLIAGE\n#define BLADE\n")
grass_mesh = grass_blade_mesh(4)
grass_wind = fl(2.4)
grass_s0 = fl(0.11)
grass_d0 = fi(45)
grass_radius = fi(1600)
if Os.has_env("R3D_NOBLADES") { grass_on = false }
if Os.has_env("R3D_GRASS_R") { grass_radius = fi(Text.to_int(Os.env("R3D_GRASS_R"))) }
if Os.has_env("R3D_GRASS_DBG") { grass_dbg = Text.to_int(Os.env("R3D_GRASS_DBG")) }
}
# indices per 16 m cell that could exist at distance d (the count the shader computes)
function grass_count_at(d: int) -> int {
let spacing = f_mul(grass_s0, f_add(F_ONE, f_div(d, grass_d0)))
let n = f_div(fi(GRASS_CELL * GRASS_CELL), f_mul(spacing, spacing))
return f_to_int(n) + 1
}
# one tile size over one distance band
function grass_tiles(size: int, d_min: int, d_max: int) -> void {
let p = grass_prog
let cells = size / GRASS_CELL
gl_uniform1i(gl_uniform(p, "u_tile_cells"), cells)
let sz = fi(size)
let half = f_mul(sz, F_HALF)
let reach = f_add(d_max, f_mul(half, fl(1.5)))
let tx0 = f_to_int(f_floor(f_div(f_sub(cam_pos[0], reach), sz)))
let tx1 = f_to_int(f_floor(f_div(f_add(cam_pos[0], reach), sz)))
let tz0 = f_to_int(f_floor(f_div(f_sub(cam_pos[2], reach), sz)))
let tz1 = f_to_int(f_floor(f_div(f_add(cam_pos[2], reach), sz)))
let corner_r = f_mul(half, fl(1.42))
var tz = tz0
while tz <= tz1 {
var tx = tx0
while tx <= tx1 {
let ox = f_mul(fi(tx), sz); let oz = f_mul(fi(tz), sz)
let cx = f_add(ox, half); let cz = f_add(oz, half)
let dx = f_sub(cx, cam_pos[0]); let dz = f_sub(cz, cam_pos[2])
let dc = f_sqrt(f_add(f_mul(dx, dx), f_mul(dz, dz)))
# the tile's nearest and farthest points decide which band it belongs to
let dnear = f_max(f_sub(dc, corner_r), F_ZERO)
if f_ls(dc, d_min) or not f_ls(dnear, d_max) { tx += 1; continue }
let cy = terrain_height(cx, cz)
if cam_sphere_visible(cx, cy, cz, f_add(corner_r, fi(6))) {
let per = grass_count_at(dnear)
if per > 0 {
u_f2(gl_uniform(p, "u_tile"), ox, oz)
gl_uniform1i(gl_uniform(p, "u_per_cell"), per)
mesh_draw_instanced(grass_mesh, per * cells * cells)
grass_draws += 1
}
}
tx += 1
}
tz += 1
}
}
function grass_draw() -> void {
if not grass_on or ter_reflect or grass_prog == 0 { return }
let p = grass_prog
gl_use_program(p)
u_mat4(gl_uniform(p, "u_view"), cam_view)
u_mat4(gl_uniform(p, "u_proj"), cam_proj)
u_mat4(gl_uniform(p, "u_vp"), cam_vp_clean)
u_f(gl_uniform(p, "u_wind"), grass_wind)
u_f(gl_uniform(p, "u_rough_scale"), F_ONE)
u_v3(gl_uniform(p, "u_tint"), sc_blade_tint)
u_v3(gl_uniform(p, "u_blade_base"), sc_blade_base)
u_v3(gl_uniform(p, "u_blade_tip"), sc_blade_tip)
u_f(gl_uniform(p, "u_cull"), grass_radius)
u_f(gl_uniform(p, "u_model_h"), F_ZERO)
u_f(gl_uniform(p, "u_s0"), grass_s0)
u_f(gl_uniform(p, "u_d0"), grass_d0)
u_f(gl_uniform(p, "u_radius"), grass_radius)
gl_uniform1i(gl_uniform(p, "u_dbg"), grass_dbg)
var orthotex = ter_ortho_tex
var oon = F_ONE
if orthotex == 0 { orthotex = ter_height_tex; oon = F_ZERO }
r3d_bind_2d(p, "u_ortho", 4, orthotex)
u_f(gl_uniform(p, "u_ortho_on"), oon)
var lake = fl(-100000.0)
if ter_lake_ex != 0 { lake = ter_lake_level }
u_f(gl_uniform(p, "u_lake_level"), lake)
u_f(gl_uniform(p, "u_snow_line"), ter_snow_line)
sky_bind_lighting(p)
shadow_bind(p)
fog_bind(p)
u_f(gl_uniform(p, "u_spec_scale"), fl(0.15))
gl_disable(GL_CULL_FACE)
grass_draws = 0
gl_bind_vertex_array(grass_mesh.vao)
grass_tiles(16, F_ZERO, fi(300))
grass_tiles(64, fi(300), fi(1200))
grass_tiles(256, fi(1200), grass_radius)
gl_enable(GL_CULL_FACE)
}

View file

@ -0,0 +1,150 @@
# ============================================================================
# mesh.ludic — vertex data on the GPU: a Mesh record (VAO + buffers + a draw
# call), and the procedural meshes the renderer needs (a grid for the terrain,
# a full-screen triangle, a unit quad for instanced cards).
# ============================================================================
property Mesh {
vao: int = 0,
vbo: int = 0,
ebo: int = 0,
count: int = 0, # indices (ebo != 0) or vertices
mode: int = 4, # GL_TRIANGLES
itype: int = 0x1405 # GL_UNSIGNED_INT
}
function mesh_draw(m: Mesh) -> void {
gl_bind_vertex_array(m.vao)
if m.ebo != 0 { gl_draw_elements(m.mode, m.count, m.itype, null) }
else { gl_draw_arrays(m.mode, 0, m.count) }
}
function mesh_draw_instanced(m: Mesh, n: int) -> void {
gl_bind_vertex_array(m.vao)
if m.ebo != 0 { gl_draw_elements_instanced(m.mode, m.count, m.itype, null, n) }
else { gl_draw_arrays_instanced(m.mode, 0, m.count, n) }
}
# A flat n x n vertex grid over [-half, half]^2 in x/z, y = 0. Attribute 0 = (x, z).
# The terrain vertex shader lifts it with the height map.
function mesh_grid(n: int, half: int) -> Mesh {
let m = new Mesh
m.vao = gl_vao()
let nv = n * n
let v = gl_floats(nv * 2)
var k = 0
for j in 0 .. n {
for i in 0 .. n {
let x = f_sub(f_mul(f_mul(fr(i, n - 1), F_TWO), half), half)
let z = f_sub(f_mul(f_mul(fr(j, n - 1), F_TWO), half), half)
gl_put_bits(v, k, x); gl_put_bits(v, k + 1, z)
k += 2
}
}
m.vbo = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, m.vbo)
gl_buffer_data(GL_ARRAY_BUFFER, gl_bytes_of(nv * 2), v, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(0)
gl_vertex_attrib_pointer(0, 2, GL_FLOAT, 0, 8, null)
free(v)
let ni = (n - 1) * (n - 1) * 6
let idx = words(ni)
k = 0
for j in 0 .. n - 1 {
for i in 0 .. n - 1 {
let a = j * n + i
idx[k] = a; idx[k + 1] = a + n; idx[k + 2] = a + 1
idx[k + 3] = a + 1; idx[k + 4] = a + n; idx[k + 5] = a + n + 1
k += 6
}
}
m.ebo = gl_buffer()
gl_bind_buffer(GL_ELEMENT_ARRAY_BUFFER, m.ebo)
gl_buffer_data(GL_ELEMENT_ARRAY_BUFFER, ni * 4, idx, GL_STATIC_DRAW)
free(idx)
m.count = ni
gl_bind_vertex_array(0)
return m
}
# The grid as patches (4 control points per cell) for tessellation shaders.
function mesh_grid_patches(n: int, half: int) -> Mesh {
let m = mesh_grid(n, half)
# rebuild the index buffer as quads
let nq = (n - 1) * (n - 1) * 4
let idx = words(nq)
var k = 0
for j in 0 .. n - 1 {
for i in 0 .. n - 1 {
let a = j * n + i
idx[k] = a; idx[k + 1] = a + 1; idx[k + 2] = a + n + 1; idx[k + 3] = a + n
k += 4
}
}
gl_bind_vertex_array(m.vao)
gl_bind_buffer(GL_ELEMENT_ARRAY_BUFFER, m.ebo)
gl_buffer_data(GL_ELEMENT_ARRAY_BUFFER, nq * 4, idx, GL_STATIC_DRAW)
gl_bind_vertex_array(0)
free(idx)
m.count = nq
m.mode = GL_PATCHES
return m
}
# A full-screen triangle with no attributes (the vertex shader uses gl_VertexID).
function mesh_fullscreen() -> Mesh {
let m = new Mesh
m.vao = gl_vao()
gl_bind_vertex_array(0)
m.count = 3
return m
}
# A unit quad in x/y ([-0.5, 0.5] x [0, 1]) with uv, attribute 0 = xy, 1 = uv.
function mesh_card() -> Mesh {
let m = new Mesh
m.vao = gl_vao()
let v = gl_floats(16)
gl_put(v, 0, -0.5); gl_put(v, 1, 0.0); gl_put(v, 2, 0.0); gl_put(v, 3, 0.0)
gl_put(v, 4, 0.5); gl_put(v, 5, 0.0); gl_put(v, 6, 1.0); gl_put(v, 7, 0.0)
gl_put(v, 8, 0.5); gl_put(v, 9, 1.0); gl_put(v, 10, 1.0); gl_put(v, 11, 1.0)
gl_put(v, 12, -0.5); gl_put(v, 13, 1.0); gl_put(v, 14, 0.0); gl_put(v, 15, 1.0)
m.vbo = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, m.vbo)
gl_buffer_data(GL_ARRAY_BUFFER, 64, v, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(0)
gl_vertex_attrib_pointer(0, 2, GL_FLOAT, 0, 16, null)
gl_enable_vertex_attrib_array(1)
gl_vertex_attrib_pointer(1, 2, GL_FLOAT, 0, 16, gl_ptr(null, 8))
free(v)
let idx = words(6)
idx[0] = 0; idx[1] = 1; idx[2] = 2; idx[3] = 0; idx[4] = 2; idx[5] = 3
m.ebo = gl_buffer()
gl_bind_buffer(GL_ELEMENT_ARRAY_BUFFER, m.ebo)
gl_buffer_data(GL_ELEMENT_ARRAY_BUFFER, 24, idx, GL_STATIC_DRAW)
free(idx)
m.count = 6
gl_bind_vertex_array(0)
return m
}
# Attach a per-instance float buffer (n floats per instance, split into vec4
# attributes from `first_attr`) to a mesh's VAO. Returns the buffer id.
function mesh_instance_buffer(m: Mesh, first_attr: int, floats_per: int, data: pointer, count: int) -> int {
gl_bind_vertex_array(m.vao)
let b = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, b)
gl_buffer_data(GL_ARRAY_BUFFER, gl_bytes_of(floats_per * count), data, GL_STATIC_DRAW)
var a = 0
var off = 0
while off < floats_per {
var sz = floats_per - off
if sz > 4 { sz = 4 }
gl_enable_vertex_attrib_array(first_attr + a)
gl_vertex_attrib_pointer(first_attr + a, sz, GL_FLOAT, 0, floats_per * 4, gl_ptr(null, off * 4))
gl_vertex_attrib_divisor(first_attr + a, 1)
a += 1
off += 4
}
gl_bind_vertex_array(0)
return b
}

View file

@ -0,0 +1,335 @@
# ============================================================================
# overlay.ludic — 2D drawing over the finished frame, in screen pixels with the
# origin top-left (the same space Input.mouse_x/y report): filled rectangles,
# textured quads and text from a baked font atlas (tools/blender/font_build.py).
# Quads are batched into one buffer for the whole frame and uploaded ONCE at ov_end;
# a texture change only closes a draw range. (On Apple's GL every glBufferSubData
# flushes the context and waits for the GPU; uploading per texture change made a HUD
# with dozens of changes wait dozens of times a frame: 26 ms -> 40 ms, sampled.)
# ============================================================================
const OV_MAX_QUADS: int = 6000
const OV_FLOATS: int = 8 # x, y, u, v, r, g, b, a
var ov_prog: int = 0
var ov_vao: int = 0
var ov_vbo: int = 0
var ov_buf: pointer = null
var ov_n: int = 0
var ov_tex: int = 0
var ov_white: int = 0
var ov_font: int = 0
var ov_font_adv: words = null # 95 float bits, em units
var ov_font_cols: int = 16
var ov_font_rows: int = 6
var ov_font_cell: int = 128 # px per cell in the atlas
var ov_font_em: int = 100 # px per em in the atlas
var ov_pad_x: int = 0 # float bits, em
var ov_base_y: int = 0
var ov_ready: bool = false
var ov_open: bool = false
var ov_dbg: bool = false
const OV_MAX_RANGES: int = 512
const OV_RANGE_W: int = 7 # texture, first quad, quad count, clip x, y, w, h (w = 0: none)
var ov_ranges: words = null
var ov_nr: int = 0
var ov_range_start: int = 0
var ov_clip_x: int = 0 # the current clip rectangle in screen pixels (top-left origin)
var ov_clip_y: int = 0
var ov_clip_w: int = 0
var ov_clip_h: int = 0
function overlay_init(font_dir: string) -> bool {
ov_prog = gl_program("#version 410 core\n" + r3d_shader_file("overlay.vert"), "#version 410 core\n" + r3d_shader_file("overlay.frag"))
if ov_prog == 0 { print("overlay: program failed"); return false }
ov_vao = gl_vao()
gl_bind_vertex_array(ov_vao)
ov_vbo = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, ov_vbo)
gl_buffer_data(GL_ARRAY_BUFFER, gl_bytes_of(OV_MAX_QUADS * 6 * OV_FLOATS), null, GL_DYNAMIC_DRAW)
gl_enable_vertex_attrib_array(0); gl_vertex_attrib_pointer(0, 2, GL_FLOAT, 0, OV_FLOATS * 4, null)
gl_enable_vertex_attrib_array(1); gl_vertex_attrib_pointer(1, 2, GL_FLOAT, 0, OV_FLOATS * 4, gl_ptr(null, 8))
gl_enable_vertex_attrib_array(2); gl_vertex_attrib_pointer(2, 4, GL_FLOAT, 0, OV_FLOATS * 4, gl_ptr(null, 16))
gl_bind_vertex_array(0)
ov_buf = gl_floats(OV_MAX_QUADS * 6 * OV_FLOATS)
ov_ranges = words(OV_MAX_RANGES * OV_RANGE_W)
ov_white = tex_solid(255, 255, 255, 255)
# the font
ov_font_adv = words(95)
for i in 0 .. 95 { ov_font_adv[i] = fl(0.6) }
ov_pad_x = fl(0.14); ov_base_y = fl(0.30)
let meta = Fs.read_text(font_dir + "/font.json")
if meta != null {
let j = Json.parse(meta)
ov_font_cols = jint(j, "cols", 16); ov_font_rows = jint(j, "rows", 6)
ov_font_cell = f_to_int(jnum(value_get(j, "cell"))); ov_font_em = f_to_int(jnum(value_get(j, "em")))
ov_pad_x = jnum(value_get(j, "pad_x")); ov_base_y = jnum(value_get(j, "base_y"))
let adv = value_get(j, "adv")
for i in 0 .. 95 { if i < value_count(adv) { ov_font_adv[i] = jnum(value_at(adv, i)) } }
ov_font = tex_load_ex(font_dir + "/font.png", false, 0)
}
if ov_font == 0 { print("overlay: no font atlas, text disabled") }
ov_dbg = Os.has_env("R3D_FONTDBG")
ov_ready = true
return true
}
# start drawing onto the screen: blending on, depth off
function ov_begin() -> void {
if not ov_ready { return }
gl_bind_framebuffer(GL_FRAMEBUFFER, gl_screen_fbo())
gl_viewport(0, 0, gl_w, gl_h)
gl_disable(GL_DEPTH_TEST)
gl_disable(GL_CULL_FACE)
gl_enable(GL_BLEND)
gl_blend_func(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
gl_use_program(ov_prog)
u_f2(gl_uniform(ov_prog, "u_screen"), fi(gl_w), fi(gl_h))
ov_n = 0; ov_nr = 0; ov_range_start = 0
ov_tex = ov_white
ov_clip_w = 0
ov_open = true
}
# everything drawn until ov_unclip stays inside this rectangle (a scrolling list)
function ov_clip(x: int, y: int, w: int, h: int) -> void {
ov_close_range()
ov_clip_x = x; ov_clip_y = y; ov_clip_w = w; ov_clip_h = h
}
function ov_unclip() -> void { ov_close_range(); ov_clip_w = 0 }
# close the current draw range (quads since its start, with the current texture)
function ov_close_range() -> void {
let n = ov_n - ov_range_start
if n <= 0 { return }
if ov_nr >= OV_MAX_RANGES { ov_flush(); return }
let o = ov_nr * OV_RANGE_W
ov_ranges[o] = ov_tex; ov_ranges[o + 1] = ov_range_start; ov_ranges[o + 2] = n
ov_ranges[o + 3] = ov_clip_x; ov_ranges[o + 4] = ov_clip_y; ov_ranges[o + 5] = ov_clip_w; ov_ranges[o + 6] = ov_clip_h
ov_nr += 1
ov_range_start = ov_n
}
# one upload of everything batched so far, then a draw per range
function ov_flush() -> void {
ov_close_range()
if ov_n == 0 { ov_nr = 0; ov_range_start = 0; return }
gl_use_program(ov_prog)
gl_bind_vertex_array(ov_vao)
gl_bind_buffer(GL_ARRAY_BUFFER, ov_vbo)
gl_buffer_data(GL_ARRAY_BUFFER, gl_bytes_of(ov_n * 6 * OV_FLOATS), ov_buf, GL_STREAM_DRAW)
var last = -1
var clipped = false
for i in 0 .. ov_nr {
let o = i * OV_RANGE_W
let t = ov_ranges[o]
if t != last {
r3d_bind_2d(ov_prog, "u_tex", 0, t)
var is_font = F_ZERO
if t == ov_font { is_font = F_ONE }
u_f(gl_uniform(ov_prog, "u_is_font"), is_font)
last = t
}
if ov_ranges[o + 5] > 0 {
if not clipped { gl_enable(GL_SCISSOR_TEST); clipped = true }
gl_scissor(ov_ranges[o + 3], gl_h - ov_ranges[o + 4] - ov_ranges[o + 6], ov_ranges[o + 5], ov_ranges[o + 6])
} else if clipped { gl_disable(GL_SCISSOR_TEST); clipped = false }
gl_draw_arrays(GL_TRIANGLES, ov_ranges[o + 1] * 6, ov_ranges[o + 2] * 6)
}
if clipped { gl_disable(GL_SCISSOR_TEST) }
gl_bind_vertex_array(0)
ov_n = 0; ov_nr = 0; ov_range_start = 0
}
function ov_end() -> void {
if not ov_open { return }
ov_flush()
gl_disable(GL_BLEND)
gl_enable(GL_DEPTH_TEST)
ov_open = false
}
function ov_use_tex(t: int) -> void {
if t != ov_tex { ov_close_range(); ov_tex = t }
}
# one vertex into the batch
function ov_vert(k: int, x: int, y: int, u: int, v: int, r: int, g: int, b: int, a: int) -> void {
let o = k * OV_FLOATS
gl_put_bits(ov_buf, o, x); gl_put_bits(ov_buf, o + 1, y)
gl_put_bits(ov_buf, o + 2, u); gl_put_bits(ov_buf, o + 3, v)
gl_put_bits(ov_buf, o + 4, r); gl_put_bits(ov_buf, o + 5, g); gl_put_bits(ov_buf, o + 6, b); gl_put_bits(ov_buf, o + 7, a)
}
# a textured quad, float-bit pixel corners and uvs
function ov_quad(x0: int, y0: int, x1: int, y1: int, u0: int, v0: int, u1: int, v1: int, r: int, g: int, b: int, a: int) -> void {
if ov_n >= OV_MAX_QUADS { ov_flush() }
let k = ov_n * 6
ov_vert(k, x0, y0, u0, v0, r, g, b, a)
ov_vert(k + 1, x1, y0, u1, v0, r, g, b, a)
ov_vert(k + 2, x1, y1, u1, v1, r, g, b, a)
ov_vert(k + 3, x0, y0, u0, v0, r, g, b, a)
ov_vert(k + 4, x1, y1, u1, v1, r, g, b, a)
ov_vert(k + 5, x0, y1, u0, v1, r, g, b, a)
ov_n += 1
}
# a filled rectangle at integer pixels; colour as float bits 0..1
function ov_rect(x: int, y: int, w: int, h: int, r: int, g: int, b: int, a: int) -> void {
ov_use_tex(ov_white)
ov_quad(fi(x), fi(y), fi(x + w), fi(y + h), F_ZERO, F_ZERO, F_ONE, F_ONE, r, g, b, a)
}
function ov_frame(x: int, y: int, w: int, h: int, t: int, r: int, g: int, b: int, a: int) -> void {
ov_rect(x, y, w, t, r, g, b, a)
ov_rect(x, y + h - t, w, t, r, g, b, a)
ov_rect(x, y, t, h, r, g, b, a)
ov_rect(x + w - t, y, t, h, r, g, b, a)
}
# a whole texture at integer pixels
function ov_image(tex: int, x: int, y: int, w: int, h: int, a: int) -> void {
ov_use_tex(tex)
ov_quad(fi(x), fi(y), fi(x + w), fi(y + h), F_ZERO, F_ZERO, F_ONE, F_ONE, F_ONE, F_ONE, F_ONE, a)
}
# the width in pixels of `s` at `size` pixels per em
function ov_text_w(size: int, s: string) -> int {
var w = F_ZERO
let sp: pointer = s # bytes, not one-character strings
let n = len(sp)
for i in 0 .. n {
var c = sp[i] - 32
if c < 0 or c > 94 { c = 0 }
w = f_add(w, f_mul(ov_font_adv[c], fi(size)))
}
return f_to_int(w)
}
# text with its top-left at (x, y); returns the pen x after it
function ov_text(x: int, y: int, size: int, s: string, r: int, g: int, b: int, a: int) -> int {
if ov_font == 0 { return x }
ov_use_tex(ov_font)
let k = fr(size, ov_font_em) # atlas px -> screen px
let cell = f_mul(fi(ov_font_cell), k)
var pen = fi(x)
let base = f_add(fi(y), f_mul(fi(size), fl(0.80)))
let px = f_mul(f_mul(ov_pad_x, fi(ov_font_em)), k)
let py = f_mul(f_mul(ov_base_y, fi(ov_font_em)), k)
let sp: pointer = s
let n = len(sp)
for i in 0 .. n {
var c = sp[i] - 32
if c < 0 or c > 94 { c = 0 }
if c != 0 {
let cx = c - (c / ov_font_cols) * ov_font_cols
let cy = c / ov_font_cols
let u0 = fr(cx, ov_font_cols); let u1 = fr(cx + 1, ov_font_cols)
let v0 = fr(cy, ov_font_rows); let v1 = fr(cy + 1, ov_font_rows)
let x0 = f_sub(pen, px); let y1 = f_add(base, py)
ov_quad(x0, f_sub(y1, cell), f_add(x0, cell), y1, u0, v0, u1, v1, r, g, b, a)
}
pen = f_add(pen, f_mul(ov_font_adv[c], fi(size)))
}
return f_to_int(pen)
}
# text with a soft dark shadow under it (HUD over a bright meadow)
function ov_text_sh(x: int, y: int, size: int, s: string, r: int, g: int, b: int, a: int) -> int {
let d = size / 18 + 1
ov_text(x + d, y + d, size, s, F_ZERO, F_ZERO, F_ZERO, f_mul(a, fl(0.7)))
return ov_text(x, y, size, s, r, g, b, a)
}
function ov_text_center(cx: int, y: int, size: int, s: string, r: int, g: int, b: int, a: int) -> void {
ov_text(cx - ov_text_w(size, s) / 2, y, size, s, r, g, b, a)
}
# text wrapped at `maxw` pixels on spaces; returns the y after the last line
function ov_text_wrap(x: int, y: int, size: int, maxw: int, s: string, r: int, g: int, b: int, a: int) -> int {
let sp: pointer = s
let n = len(sp)
var first = 0
var ly = y
while first < n {
var last_space = -1
var i = first
var stop = n
while i < n {
if sp[i] == 10 { stop = i; break }
if sp[i] == 32 { last_space = i }
let piece: string = s[first .. i + 1]
if ov_text_w(size, piece) > maxw and last_space > first { stop = last_space; break }
i += 1
}
let line: string = s[first .. stop]
ov_text(x, ly, size, line, r, g, b, a)
ly += size * 13 / 10
first = stop
while first < n and (sp[first] == 32 or sp[first] == 10) { first += 1 }
}
return ly
}
# ---- more shapes for a game's interface -------------------------------------------------
# a sub-rectangle of a texture (uv corners as float bits) tinted, at integer pixels
function ov_sub(tex: int, x: int, y: int, w: int, h: int, u0: int, v0: int, u1: int, v1: int, r: int, g: int, b: int, a: int) -> void {
ov_use_tex(tex)
ov_quad(fi(x), fi(y), fi(x + w), fi(y + h), u0, v0, u1, v1, r, g, b, a)
}
# a whole texture stretched by nine slices: corners `src` texels wide in a `tw` px square
# texture, drawn `dst` pixels wide, so rounded corners keep their shape at any size
function ov_nine(tex: int, tw: int, src: int, x: int, y: int, w: int, h: int, dst: int, r: int, g: int, b: int, a: int) -> void {
ov_use_tex(tex)
let s = fr(src, tw)
let xs = words(4); let ys = words(4); let us = words(4); let vs = words(4)
xs[0] = fi(x); xs[1] = fi(x + dst); xs[2] = fi(x + w - dst); xs[3] = fi(x + w)
ys[0] = fi(y); ys[1] = fi(y + dst); ys[2] = fi(y + h - dst); ys[3] = fi(y + h)
us[0] = F_ZERO; us[1] = s; us[2] = f_sub(F_ONE, s); us[3] = F_ONE
vs[0] = F_ZERO; vs[1] = s; vs[2] = f_sub(F_ONE, s); vs[3] = F_ONE
for j in 0 .. 3 {
for i in 0 .. 3 { ov_quad(xs[i], ys[j], xs[i + 1], ys[j + 1], us[i], vs[j], us[i + 1], vs[j + 1], r, g, b, a) }
}
free(xs); free(ys); free(us); free(vs)
}
# an arbitrary quad (float-bit pixel corners, clockwise from top-left) of a texture
function ov_quad4(x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, x3: int, y3: int, u0: int, v0: int, u1: int, v1: int, r: int, g: int, b: int, a: int) -> void {
if ov_n >= OV_MAX_QUADS { ov_flush() }
let k = ov_n * 6
ov_vert(k, x0, y0, u0, v0, r, g, b, a)
ov_vert(k + 1, x1, y1, u1, v0, r, g, b, a)
ov_vert(k + 2, x2, y2, u1, v1, r, g, b, a)
ov_vert(k + 3, x0, y0, u0, v0, r, g, b, a)
ov_vert(k + 4, x2, y2, u1, v1, r, g, b, a)
ov_vert(k + 5, x3, y3, u0, v1, r, g, b, a)
ov_n += 1
}
# a line of thickness `t` pixels between two points (float-bit pixels)
function ov_line(x0: int, y0: int, x1: int, y1: int, t: int, r: int, g: int, b: int, a: int) -> void {
ov_use_tex(ov_white)
let dx = f_sub(x1, x0); let dy = f_sub(y1, y0)
let l = f_max(f_sqrt(f_add(f_mul(dx, dx), f_mul(dy, dy))), fl(0.001))
let nx = f_mul(f_div(f_neg(dy), l), f_mul(t, F_HALF)); let ny = f_mul(f_div(dx, l), f_mul(t, F_HALF))
ov_quad4(f_add(x0, nx), f_add(y0, ny), f_add(x1, nx), f_add(y1, ny), f_sub(x1, nx), f_sub(y1, ny), f_sub(x0, nx), f_sub(y0, ny), F_ZERO, F_ZERO, F_ONE, F_ONE, r, g, b, a)
}
# a sub-rectangle of a texture rotated by `ang` radians about its centre (cx, cy), `w` x `h` pixels
function ov_sub_rot(tex: int, cx: int, cy: int, w: int, h: int, ang: int, u0: int, v0: int, u1: int, v1: int, r: int, g: int, b: int, a: int) -> void {
ov_use_tex(tex)
let c = f_cos(ang); let s = f_sin(ang)
let hw = f_mul(fi(w), F_HALF); let hh = f_mul(fi(h), F_HALF)
let fx = fi(cx); let fy = fi(cy)
# corners: (-hw,-hh) (hw,-hh) (hw,hh) (-hw,hh) rotated
let x0 = f_add(fx, f_sub(f_mul(f_neg(hw), c), f_mul(f_neg(hh), s))); let y0 = f_add(fy, f_add(f_mul(f_neg(hw), s), f_mul(f_neg(hh), c)))
let x1 = f_add(fx, f_sub(f_mul(hw, c), f_mul(f_neg(hh), s))); let y1 = f_add(fy, f_add(f_mul(hw, s), f_mul(f_neg(hh), c)))
let x2 = f_add(fx, f_sub(f_mul(hw, c), f_mul(hh, s))); let y2 = f_add(fy, f_add(f_mul(hw, s), f_mul(hh, c)))
let x3 = f_add(fx, f_sub(f_mul(f_neg(hw), c), f_mul(hh, s))); let y3 = f_add(fy, f_add(f_mul(f_neg(hw), s), f_mul(hh, c)))
ov_quad4(x0, y0, x1, y1, x2, y2, x3, y3, u0, v0, u1, v1, r, g, b, a)
}
# a filled circle approximated by `n` wedges (float-bit centre and radius)
function ov_disc(cx: int, cy: int, rad: int, n: int, r: int, g: int, b: int, a: int) -> void {
ov_use_tex(ov_white)
let step = f_div(f_mul(F_TWO, F_PI), fi(n))
for i in 0 .. n {
let a0 = f_mul(fi(i), step); let a1 = f_add(a0, step)
let ax = f_add(cx, f_mul(f_cos(a0), rad)); let ay = f_add(cy, f_mul(f_sin(a0), rad))
let bx = f_add(cx, f_mul(f_cos(a1), rad)); let by = f_add(cy, f_mul(f_sin(a1), rad))
ov_quad4(cx, cy, ax, ay, bx, by, cx, cy, F_ZERO, F_ZERO, F_ONE, F_ONE, r, g, b, a)
}
}
# a ring: `n` segments of thickness `t`, from angle a0 for `span` radians (float bits)
function ov_arc(cx: int, cy: int, rad: int, t: int, a0: int, span: int, n: int, r: int, g: int, b: int, a: int) -> void {
let step = f_div(span, fi(n))
for i in 0 .. n {
let b0 = f_add(a0, f_mul(fi(i), step)); let b1 = f_add(b0, step)
ov_line(f_add(cx, f_mul(f_cos(b0), rad)), f_add(cy, f_mul(f_sin(b0), rad)), f_add(cx, f_mul(f_cos(b1), rad)), f_add(cy, f_mul(f_sin(b1), rad)), t, r, g, b, a)
}
}

View file

@ -0,0 +1,7 @@
# ludic.render3d — a physically based 3D renderer over Gl.* (OpenGL 4.1 core):
# HDRI sky + image-based lighting, a GPU-generated terrain with scanned PBR
# materials, cascaded shadow maps, an HDR pipeline with bloom and ACES.
package "ludic.render3d"
version "0.1.0"
kind source
provides "R3d"

View file

@ -0,0 +1,307 @@
# ============================================================================
# post.ludic — the HDR frame and what happens to it: a 16-bit float scene
# target, a mip-chain bloom (13-tap down, tent up), and the tonemap composite
# (exposure, ACES, vignette, saturation, contrast, dither) to the screen.
# ============================================================================
const BLOOM_LEVELS: int = 6
var post_hdr: Target = null
var post_ms_fbo: int = 0 # 4x multisampled scene target, resolved into post_hdr
var post_ms_samples: int = 1 # temporal AA carries the edges; R3D_MSAA=n to compare
var post_bloom: []Target = null
var post_p_down: int = 0
var post_p_up: int = 0
var post_p_tone: int = 0
var post_fs: Mesh = null
var post_exposure: int = 0
var post_bloom_strength: int = 0
var post_vignette: int = 0
var post_saturation: int = 0
var post_contrast: int = 0
var post_w: int = 0
var post_h: int = 0
var post_auto: bool = true
var post_key: int = 0 # target mean luminance after exposure (float bits)
var post_lum: words = null
var post_mips: int = 0
var post_adapt: int = 0 # smoothed exposure (float bits)
var post_exposure_max: int = 0x41A00000 # 20: the ceiling auto-exposure may reach (night lowers it)
var post_ao: Target = null
var post_ao_blur: Target = null
var post_p_ao: int = 0
var post_p_ao_blur: int = 0
var post_ao_radius: int = 0
var post_ao_intensity: int = 0
var post_ao_strength: int = 0
var post_gi_strength: int = 0x3ECCCCCD # 0.4
var post_no_gi: bool = false
var post_ldr: Target = null
var post_depth_copy: Target = null
var post_prev: Target = null # last frame's scene colour, for the SSGI bounce only
var post_scene: Target = null # this frame's scene colour before the water, for refraction
var post_frame: 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
var post_sharpen: int = 0
var post_grain: int = 0
# the screen-sized targets go away before post_init makes them at a new size
function post_free() -> void {
if post_hdr == null { return }
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)
for i in 0 .. len(post_bloom) { target_free(post_bloom[i]) }
post_hdr = null
}
function post_init(w: int, h: int) -> void {
post_w = w; post_h = h
post_hdr = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, true, GL_LINEAR)
if post_ms_samples > 1 {
post_ms_fbo = gl_framebuffer()
gl_bind_framebuffer(GL_FRAMEBUFFER, post_ms_fbo)
let ids = gl_scratch()
gl_gen_renderbuffers(1, ids)
gl_bind_renderbuffer(GL_RENDERBUFFER, ids[0])
gl_renderbuffer_storage_multisample(GL_RENDERBUFFER, post_ms_samples, GL_RGBA16F, w, h)
gl_framebuffer_renderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, ids[0])
gl_gen_renderbuffers(1, ids)
gl_bind_renderbuffer(GL_RENDERBUFFER, ids[0])
gl_renderbuffer_storage_multisample(GL_RENDERBUFFER, post_ms_samples, GL_DEPTH_COMPONENT32F, w, h)
gl_framebuffer_renderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, ids[0])
let st = gl_check_framebuffer_status(GL_FRAMEBUFFER)
if st != GL_FRAMEBUFFER_COMPLETE { print(`r3d: msaa framebuffer incomplete {st}`); post_ms_fbo = 0 }
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
}
post_bloom = new []Target
var bw = w / 2; var bh = h / 2
for i in 0 .. BLOOM_LEVELS {
push(post_bloom, target_new(max(bw, 1), max(bh, 1), GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR))
bw = bw / 2; bh = bh / 2
}
if post_p_down == 0 {
post_p_down = r3d_program("fullscreen.vert", "bloom_down.frag", "")
post_p_up = r3d_program("fullscreen.vert", "bloom_up.frag", "")
post_p_tone = r3d_program("fullscreen.vert", "tonemap.frag", "")
}
# Full resolution, not half. The occlusion is reconstructed from depth differences,
# so on a surface seen at a grazing angle its gradient is steep in screen space; at
# half resolution that aliased into wide, screen-crossing bands which the bilinear
# upsample in the tonemapper then stretched over the whole ground. They read as thin
# transparent black bars, appear only where there is depth (never on the sky), and
# are nothing to do with the shadow map or the reflection.
post_ao = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
post_ao_blur = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_ao == 0 { post_p_ao = r3d_program("fullscreen.vert", "ssgi.frag", ""); post_p_ao_blur = r3d_program("fullscreen.vert", "ssao_blur.frag", "") }
post_ldr = target_new(w, h, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, false, GL_LINEAR)
post_depth_copy = target_new(w, h, GL_R8, GL_RED, GL_UNSIGNED_BYTE, true, GL_NEAREST)
post_prev = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
post_scene = target_new(w, h, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, false, GL_LINEAR)
if post_p_sharp == 0 { post_p_sharp = r3d_program("fullscreen.vert", "sharpen.frag", "") }
post_sharpen = fl(1.2)
post_grain = fl(0.025)
post_ao_radius = fl(0.7)
post_ao_intensity = fl(1.4)
post_ao_strength = fl(0.8)
post_fs = mesh_fullscreen()
post_exposure = fl(0.36)
post_bloom_strength = fl(0.06)
post_vignette = fl(0.35)
post_saturation = fl(1.04)
post_contrast = fl(1.12)
post_key = fl(0.19)
post_lum = words(4)
var m = 1; var sz = max(w, h)
while sz > 1 { sz = sz / 2; m += 1 }
post_mips = m
post_adapt = F_ZERO
}
# Mean scene luminance from the HDR mip chain -> exposure = key / mean, eased over
# frames. The value comes back through a pixel buffer one frame late: a direct
# glGetTexImage waits for the GPU to finish the whole frame, which serialised the
# CPU and the GPU. With the fly-camera demo that cost little (the CPU had nothing
# else to do); with the game's animals, HUD and rules on the CPU it doubled the frame
# (60 ms -> 28 ms when the read went asynchronous, measured 2026-09-09).
# ... and even that asynchronous read blocked on Apple's GL (glGetTexImage into a pixel
# buffer still synchronised the texture: 50% of the CPU's frame waiting, sampled), so
# the adaptation now stays on the GPU: a 1x1 pass (adapt.frag) eases last frame's value
# toward key / mean and the tonemapper samples it. The CPU never waits for the picture.
var post_adapt_t: []Target = null
var post_adapt_i: int = 0
var post_p_adapt: int = 0
var post_adapt_reset: bool = true
function post_measure() -> void {
gl_bind_texture(GL_TEXTURE_2D, post_hdr.color)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
gl_generate_mipmap(GL_TEXTURE_2D)
if post_adapt_t == null {
post_adapt_t = new []Target
for k in 0 .. 2 { push(post_adapt_t, target_new(1, 1, GL_R32F, GL_RED, GL_FLOAT, false, GL_NEAREST)) }
post_adapt_reset = true
}
if post_p_adapt == 0 { post_p_adapt = r3d_program("fullscreen.vert", "adapt.frag", "") }
let next = 1 - post_adapt_i
target_bind(post_adapt_t[next])
gl_disable(GL_DEPTH_TEST)
gl_use_program(post_p_adapt)
r3d_bind_2d(post_p_adapt, "u_scene", 0, post_hdr.color)
r3d_bind_2d(post_p_adapt, "u_prev", 1, post_adapt_t[post_adapt_i].color)
u_f(gl_uniform(post_p_adapt, "u_lod"), fi(post_mips - 1))
u_f(gl_uniform(post_p_adapt, "u_key"), post_key)
u_f(gl_uniform(post_p_adapt, "u_max"), post_exposure_max)
u_f(gl_uniform(post_p_adapt, "u_rate"), fl(0.08))
var reset = F_ZERO
if post_adapt_reset { reset = F_ONE; post_adapt_reset = false }
u_f(gl_uniform(post_p_adapt, "u_reset"), reset)
mesh_draw(post_fs)
post_adapt_i = next
gl_bind_texture(GL_TEXTURE_2D, post_hdr.color)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
}
function post_begin_scene() -> void {
target_bind(post_hdr)
if post_ms_fbo != 0 { gl_bind_framebuffer(GL_FRAMEBUFFER, post_ms_fbo); gl_enable(GL_MULTISAMPLE) }
gl_enable(GL_DEPTH_TEST)
gl_depth_func(GL_LESS)
gl_depth_mask(1)
gl_enable(GL_CULL_FACE)
gl_cull_face(GL_BACK)
gl_clear_color(0.0, 0.0, 0.0, 1.0)
gl_clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
}
# resolve the multisampled scene into the plain HDR target (colour + depth)
function post_resolve() -> void {
if post_ms_fbo != 0 {
gl_bind_framebuffer(GL_READ_FRAMEBUFFER, post_ms_fbo)
gl_bind_framebuffer(GL_DRAW_FRAMEBUFFER, post_hdr.fbo)
gl_blit_framebuffer(0, 0, post_w, post_h, 0, 0, post_w, post_h, GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT, GL_NEAREST)
}
# the depth copy every pass after this may read while the frame is still being drawn into
gl_bind_framebuffer(GL_READ_FRAMEBUFFER, post_hdr.fbo)
gl_bind_framebuffer(GL_DRAW_FRAMEBUFFER, post_depth_copy.fbo)
gl_blit_framebuffer(0, 0, post_w, post_h, 0, 0, post_w, post_h, GL_DEPTH_BUFFER_BIT, GL_NEAREST)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
}
# There is no temporal anti-aliasing. It was reprojecting every pixel through the
# scene depth, which on water is the surface plane while the pixel's content is the
# reflection behind it — so the mirror image was fetched from the wrong place and, at
# 0.92 history, dragged several frames behind the camera as it turned. Geometry edges
# and the alpha-tested vegetation are covered by the 4x MSAA + alpha-to-coverage the
# scene already renders with, and the projection is no longer jittered, so nothing is
# left needing a temporal resolve.
# The lake bed, as drawn, before any water goes over it. Water reads this to refract and
# then absorb it, which is what makes the surface read as a body of water rather than a
# sheet laid over the ground: the bottom is seen THROUGH the water, tinted and dimmed by
# how far the light travelled, instead of being the dry terrain showing through an alpha.
function post_capture_scene() -> void {
gl_bind_framebuffer(GL_READ_FRAMEBUFFER, post_hdr.fbo)
gl_bind_framebuffer(GL_DRAW_FRAMEBUFFER, post_scene.fbo)
gl_blit_framebuffer(0, 0, post_w, post_h, 0, 0, post_w, post_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)
gl_bind_framebuffer(GL_FRAMEBUFFER, post_hdr.fbo)
gl_viewport(0, 0, post_w, post_h)
}
# Keep a copy of the finished scene colour: the SSGI bounce reads last frame's colour.
function post_capture_prev() -> void {
gl_bind_framebuffer(GL_READ_FRAMEBUFFER, post_hdr.fbo)
gl_bind_framebuffer(GL_DRAW_FRAMEBUFFER, post_prev.fbo)
gl_blit_framebuffer(0, 0, post_w, post_h, 0, 0, post_w, post_h, GL_COLOR_BUFFER_BIT, GL_NEAREST)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
post_frame += 1
}
function post_ssao_pass() -> void {
gl_disable(GL_DEPTH_TEST)
gl_disable(GL_BLEND)
target_bind(post_ao)
gl_use_program(post_p_ao)
r3d_bind_2d(post_p_ao, "u_depth", 0, post_hdr.depth)
r3d_bind_2d(post_p_ao, "u_prev_color", 1, post_prev.color)
u_f(gl_uniform(post_p_ao, "u_frame"), fi(post_frame % 64))
u_mat4(gl_uniform(post_p_ao, "u_inv_proj"), cam_inv_proj)
u_mat4(gl_uniform(post_p_ao, "u_proj"), cam_proj)
u_f2(gl_uniform(post_p_ao, "u_texel"), fr(1, post_w), fr(1, post_h))
u_f(gl_uniform(post_p_ao, "u_radius"), post_ao_radius)
u_f(gl_uniform(post_p_ao, "u_intensity"), post_ao_intensity)
mesh_draw(post_fs)
target_bind(post_ao_blur)
gl_use_program(post_p_ao_blur)
r3d_bind_2d(post_p_ao_blur, "u_ao", 0, post_ao.color)
r3d_bind_2d(post_p_ao_blur, "u_depth", 1, post_hdr.depth)
u_f2(gl_uniform(post_p_ao_blur, "u_texel"), fr(1, post_ao.w), fr(1, post_ao.h))
mesh_draw(post_fs)
}
function post_bloom_pass() -> void {
gl_disable(GL_DEPTH_TEST)
gl_disable(GL_BLEND)
var src = post_color
var sw = post_w; var sh = post_h
gl_use_program(post_p_down)
for i in 0 .. BLOOM_LEVELS {
let t = post_bloom[i]
target_bind(t)
r3d_bind_2d(post_p_down, "u_src", 0, src)
u_f2(gl_uniform(post_p_down, "u_texel"), fr(1, sw), fr(1, sh))
var th = f_neg1()
if i == 0 { th = fl(1.2) }
u_f(gl_uniform(post_p_down, "u_threshold"), th)
mesh_draw(post_fs)
src = t.color; sw = t.w; sh = t.h
}
gl_use_program(post_p_up)
gl_enable(GL_BLEND)
gl_blend_func(GL_ONE, GL_ONE)
var i = BLOOM_LEVELS - 1
while i > 0 {
let from = post_bloom[i]
let to = post_bloom[i - 1]
target_bind(to)
r3d_bind_2d(post_p_up, "u_src", 0, from.color)
u_f2(gl_uniform(post_p_up, "u_texel"), fr(1, from.w), fr(1, from.h))
u_f(gl_uniform(post_p_up, "u_radius"), F_ONE)
mesh_draw(post_fs)
i -= 1
}
gl_disable(GL_BLEND)
}
function post_tonemap(color_tex: int) -> void {
if post_auto { post_measure() }
target_bind(post_ldr)
gl_disable(GL_DEPTH_TEST)
gl_use_program(post_p_tone)
r3d_bind_2d(post_p_tone, "u_hdr", 0, color_tex)
r3d_bind_2d(post_p_tone, "u_bloom", 1, post_bloom[0].color)
r3d_bind_2d(post_p_tone, "u_ao", 2, post_ao_blur.color)
u_f(gl_uniform(post_p_tone, "u_ao_strength"), post_ao_strength)
u_f(gl_uniform(post_p_tone, "u_gi_strength"), post_gi_strength)
u_f(gl_uniform(post_p_tone, "u_exposure"), post_exposure)
var auto = F_ZERO
if post_auto and post_adapt_t != null { auto = F_ONE; r3d_bind_2d(post_p_tone, "u_adapt", 3, post_adapt_t[post_adapt_i].color) }
u_f(gl_uniform(post_p_tone, "u_auto"), auto)
u_f(gl_uniform(post_p_tone, "u_bloom_strength"), post_bloom_strength)
u_f(gl_uniform(post_p_tone, "u_vignette"), post_vignette)
u_f(gl_uniform(post_p_tone, "u_saturation"), post_saturation)
u_f(gl_uniform(post_p_tone, "u_contrast"), post_contrast)
u_f3(gl_uniform(post_p_tone, "u_wb"), fl(1.02), F_ONE, fl(0.97))
u_f3(gl_uniform(post_p_tone, "u_lift"), fl(0.004), fl(0.004), fl(0.012))
u_f3(gl_uniform(post_p_tone, "u_gain"), fl(0.99), fl(0.995), fl(1.0))
mesh_draw(post_fs)
# sharpen + grain onto the screen
gl_bind_framebuffer(GL_FRAMEBUFFER, gl_screen)
gl_viewport(0, 0, gl_w, gl_h)
gl_use_program(post_p_sharp)
r3d_bind_2d(post_p_sharp, "u_src", 0, post_ldr.color)
u_f2(gl_uniform(post_p_sharp, "u_texel"), fr(1, post_w), fr(1, post_h))
u_f(gl_uniform(post_p_sharp, "u_amount"), post_sharpen)
u_f(gl_uniform(post_p_sharp, "u_grain"), post_grain)
u_f(gl_uniform(post_p_sharp, "u_time"), r3d_time)
mesh_draw(post_fs)
}

View file

@ -0,0 +1,385 @@
# ============================================================================
# prof.ludic — per-pass GPU timing (R3D_PROF=1).
#
# Wall-clock timing of this renderer is useless at pass granularity: run-to-run
# variance on a laptop GPU is ±15%, which is larger than most passes. GL_TIME_ELAPSED
# queries measure what the GPU actually spent inside each pass, and averaging a few
# hundred frames inside ONE run cancels the run-to-run noise entirely.
#
# Each slot owns a small ring of query objects. A query is read back only after
# enough frames have passed that its result is certainly available, so profiling
# never introduces the very stall it is trying to measure.
# ============================================================================
const PROF_SLOTS: int = 16
const PROF_RING: int = 4 # frames of latency before a result is read
var prof_on: bool = false
var prof_names: []pointer = null
var prof_ids: words = null # PROF_SLOTS * PROF_RING query objects
var prof_ns: []long = null # accumulated nanoseconds per slot
var prof_hits: []long = null # samples accumulated per slot
var prof_n: int = 0 # slots in use
var prof_frame: int = 0
var prof_active: int = -1 # slot whose query is currently open
var prof_scratch: words = null
function prof_init() -> void {
prof_on = Os.has_env("R3D_PROF")
if not prof_on { return }
prof_names = new []pointer
prof_ns = new []long
prof_hits = new []long
prof_ids = words(PROF_SLOTS * PROF_RING)
prof_scratch = words(4)
gl_gen_queries(PROF_SLOTS * PROF_RING, prof_ids)
prof_n = 0
prof_frame = 0
prof_active = -1
}
# the slot index for `name`, registering it on first sight (order = frame order)
function prof_slot(name: pointer) -> int {
var i = 0
while i < prof_n {
if prof_names[i] == name { return i }
i += 1
}
if prof_n >= PROF_SLOTS { return -1 }
push(prof_names, name)
push(prof_ns, 0)
push(prof_hits, 0)
prof_n += 1
return prof_n - 1
}
function prof_begin(name: pointer) -> void {
if not prof_on { return }
if prof_active >= 0 { return } # GL_TIME_ELAPSED queries cannot nest
let s = prof_slot(name)
if s < 0 { return }
prof_active = s
gl_begin_query(GL_TIME_ELAPSED, prof_ids[s * PROF_RING + (prof_frame % PROF_RING)])
}
function prof_end() -> void {
if not prof_on { return }
if prof_active < 0 { return }
gl_end_query(GL_TIME_ELAPSED)
prof_active = -1
}
# Collect the queries issued PROF_RING-1 frames ago — long finished, so no stall.
function prof_collect() -> void {
if not prof_on { return }
prof_frame += 1
if prof_frame < PROF_RING { return }
let slot_frame = (prof_frame + 1) % PROF_RING
var s = 0
while s < prof_n {
let q = prof_ids[s * PROF_RING + slot_frame]
gl_get_query_objectiv(q, GL_QUERY_RESULT_AVAILABLE, prof_scratch)
if prof_scratch[0] != 0 {
gl_get_query_objectui64v(q, GL_QUERY_RESULT, prof_scratch)
# the low 32 bits are ample: a pass is far below 4 seconds
prof_ns[s] = prof_ns[s] + prof_scratch[0]
prof_hits[s] = prof_hits[s] + 1
}
s += 1
}
}
function prof_report() -> void {
if not prof_on { return }
print("")
print("GPU time per pass (mean over the run):")
var total = 0
var s = 0
while s < prof_n {
if prof_hits[s] > 0 { total = total + prof_ns[s] / prof_hits[s] }
s += 1
}
s = 0
while s < prof_n {
if prof_hits[s] > 0 {
let us = prof_ns[s] / prof_hits[s] / 1000
var pct = 0
if total > 0 { pct = (prof_ns[s] / prof_hits[s]) * 100 / total }
print(` {Text.pad_right(prof_names[s], 22)} {Text.pad_left(string(us), 7)} us {string(pct)}%`)
}
s += 1
}
print(` {Text.pad_right("TOTAL", 22)} {Text.pad_left(string(total / 1000), 7)} us`)
}
# ---- streaming work per frame (R3D_PROF=1) ----------------------------------
# Stutter while walking is not visible in an average: the cover for a newly entered
# chunk is generated in whichever frame the camera crosses a 32 m cell, so one frame
# in fifty does all the work. `Time.delta` here is a fixed 60 Hz timestep and there is
# no finer wall clock in the runtime, so measure the WORK instead — instances generated
# per frame is exactly what the hitch is made of, and it needs no clock at all.
var prof_gen: []int = null
var prof_gen_cur: int = 0
var prof_ft: []long = null # real frame times, microseconds
var prof_last_us: long = 0
function prof_gen_add(n: int) -> void {
if not prof_on { return }
prof_gen_cur += n
}
# Per-chunk generation, so a hitch can be pinned on a stream and a band rather than on
# "streaming". One chunk is generated atomically, so the worst chunk is the worst frame.
var prof_chunk_us: []long = null
var prof_chunk_kind: []int = null
var prof_chunk_band: []int = null
var prof_chunk_n: []int = null
var prof_gen_us_cur: long = 0
var prof_gen_us: []long = null # generation microseconds per frame
# CPU work that only happens on some frames — which is what a hitch is made of.
var prof_layer_us_cur: long = 0 # rebuilding and uploading instance buffers
var prof_bake_us_cur: long = 0 # the height-field shadow rebake
var prof_layer_us: []long = null
var prof_bake_us: []long = null
var prof_dt: []long = null # frame time, aligned with the arrays above
var prof_up_cur: long = 0 # instance bytes uploaded this frame
var prof_up: []long = null
# Where a frame's time went at the coarsest useful split: work this process did, and
# time spent waiting for the GPU to finish it. Headless drains the GPU inside swap, so
# the two are cleanly separable there.
var prof_pre_swap: long = 0
var prof_cpu_us: []long = null
function prof_before_swap() -> void { if prof_on { prof_pre_swap = gl_now_us() } }
# The single most expensive CPU phase of each frame, and what it was. A hitch is one
# phase running long on one frame, so recording the worst one per frame is enough to
# name it without keeping a timeline.
var prof_mark_last: long = 0
var prof_mark_best: long = 0
var prof_mark_name: pointer = null
var prof_mark_us: []long = null
var prof_mark_who: []pointer = null
function prof_mark_start() -> void { if prof_on { prof_mark_last = gl_now_us(); prof_mark_best = 0; prof_mark_name = null } }
function prof_cpu_mark(name: pointer) -> void {
if not prof_on { return }
let now = gl_now_us()
let d = now - prof_mark_last
prof_mark_last = now
if d > prof_mark_best { prof_mark_best = d; prof_mark_name = name }
}
# the game's own per-frame work, kept apart from the renderer's
var prof_game_us_cur: long = 0
var prof_game_us: []long = null
function prof_game_add(us: long) -> void { if prof_on { prof_game_us_cur = prof_game_us_cur + us } }
function prof_layer_add(us: long, bytes: long) -> void {
if not prof_on { return }
prof_layer_us_cur = prof_layer_us_cur + us
prof_up_cur = prof_up_cur + bytes
}
function prof_bake_add(us: long) -> void { if prof_on { prof_bake_us_cur = prof_bake_us_cur + us } }
function prof_chunk(kind: int, band: int, count: int, us: long) -> void {
if not prof_on { return }
if prof_chunk_us == null {
prof_chunk_us = new []int; prof_chunk_kind = new []int
prof_chunk_band = new []int; prof_chunk_n = new []int
}
push(prof_chunk_us, us); push(prof_chunk_kind, kind)
push(prof_chunk_band, band); push(prof_chunk_n, count)
prof_gen_us_cur += us
}
function prof_chunk_report() -> void {
if not prof_on or prof_chunk_us == null { return }
print("")
print("chunk generation (one chunk is atomic, so the worst chunk is the worst frame):")
# totals per (kind, band)
var kinds = new []int
var bands = new []int
var tot = new []long
var cnt = new []int
var mx = new []long
var i = 0
while i < len(prof_chunk_us) {
var f = -1
var j = 0
while j < len(kinds) { if kinds[j] == prof_chunk_kind[i] and bands[j] == prof_chunk_band[i] { f = j }; j += 1 }
if f < 0 {
push(kinds, prof_chunk_kind[i]); push(bands, prof_chunk_band[i])
push(tot, 0); push(cnt, 0); push(mx, 0)
f = len(kinds) - 1
}
tot[f] = tot[f] + prof_chunk_us[i]
cnt[f] = cnt[f] + 1
if prof_chunk_us[i] > mx[f] { mx[f] = prof_chunk_us[i] }
i += 1
}
var k = 0
while k < len(kinds) {
print(` kind {string(kinds[k])} band {string(bands[k])}: {string(cnt[k])} chunks, mean {string(tot[k] / cnt[k])} us, worst {string(mx[k])} us, total {string(tot[k] / 1000)} ms`)
k += 1
}
# the per-frame distribution of generation time: this is the hitch itself
if prof_gen_us == null or len(prof_gen_us) < 16 { return }
let sorted = new []long
var a = 8
while a < len(prof_gen_us) { push(sorted, prof_gen_us[a]); a += 1 }
var x = 1
while x < len(sorted) {
let v = sorted[x]
var y = x - 1
while y >= 0 and sorted[y] > v { sorted[y + 1] = sorted[y]; y -= 1 }
sorted[y + 1] = v
x += 1
}
let n = len(sorted)
var busy = 0
var t: long = 0
var z = 0
while z < n { if sorted[z] > 0 { busy += 1 }; t += sorted[z]; z += 1 }
print(` generation per frame (us): p95 {string(sorted[(n * 95) / 100])} p99 {string(sorted[(n * 99) / 100])} worst {string(sorted[n - 1])} frames that generated: {string(busy)} of {string(n)} total {string(t / 1000)} ms`)
}
function prof_gen_frame() -> void {
if not prof_on { return }
if prof_gen == null {
prof_gen = new []int; prof_ft = new []long
prof_gen_us = new []long; prof_layer_us = new []long
prof_bake_us = new []long; prof_dt = new []long; prof_up = new []long
prof_cpu_us = new []long; prof_game_us = new []long
prof_mark_us = new []long; prof_mark_who = new []pointer
}
push(prof_gen, prof_gen_cur)
prof_gen_cur = 0
let now = gl_now_us()
var dtf: long = 0
if prof_last_us != 0 { push(prof_ft, now - prof_last_us); dtf = now - prof_last_us }
prof_last_us = now
# everything the frame just ended spent on work it only does sometimes
push(prof_dt, dtf)
push(prof_gen_us, prof_gen_us_cur)
push(prof_layer_us, prof_layer_us_cur)
push(prof_bake_us, prof_bake_us_cur)
push(prof_up, prof_up_cur)
var cpu: long = 0
if prof_pre_swap != 0 and dtf != 0 { cpu = prof_pre_swap - (now - dtf) }
push(prof_cpu_us, cpu)
push(prof_game_us, prof_game_us_cur)
push(prof_mark_us, prof_mark_best)
if prof_mark_name == null { push(prof_mark_who, "-") } else { push(prof_mark_who, prof_mark_name) }
prof_gen_us_cur = 0; prof_layer_us_cur = 0; prof_bake_us_cur = 0; prof_up_cur = 0; prof_game_us_cur = 0
}
# the distribution of REAL frame times: stutter lives in the tail, not the mean
function prof_ft_report() -> void {
if not prof_on { return }
if prof_ft == null or len(prof_ft) < 16 { return }
let sorted = new []long
var i = 8
while i < len(prof_ft) { push(sorted, prof_ft[i]); i += 1 }
var a = 1
while a < len(sorted) {
let v = sorted[a]
var b = a - 1
while b >= 0 and sorted[b] > v { sorted[b + 1] = sorted[b]; b -= 1 }
sorted[b + 1] = v
a += 1
}
let n = len(sorted)
let med = sorted[n / 2]
var over = 0
var j = 0
while j < n { if sorted[j] > med * 2 { over += 1 }; j += 1 }
print("")
print("REAL frame time (us):")
print(` median {string(med)} p95 {string(sorted[(n * 95) / 100])} p99 {string(sorted[(n * 99) / 100])} worst {string(sorted[n - 1])}`)
# A hitch is not the mean moving: it is the count of frames that took noticeably
# longer than the frame before them. 1.3x median is about where it stops being smooth.
var o13 = 0
var o15 = 0
var q = 0
while q < n {
if sorted[q] * 10 > med * 13 { o13 += 1 }
if sorted[q] * 2 > med * 3 { o15 += 1 }
q += 1
}
# How much time the run spent being slower than itself: the sum of every frame's
# excess over 1.2x the median. One number that goes down when hitching goes down, and
# that a handful of unlucky frames cannot dominate the way a maximum can.
var excess: long = 0
q = 0
while q < n {
let lim = (med * 12) / 10
if sorted[q] > lim { excess = excess + (sorted[q] - lim) }
q += 1
}
print(` fps at median {string(1000000 / med)} frames over 1.3x median: {string(o13)}, over 1.5x: {string(o15)}, over 2x: {string(over)} — of {string(n)}`)
print(` stutter: {string(excess / 1000)} ms of frame time beyond 1.2x median over the run`)
print(` streaming totals over the run: walk {string(stream_us_walk / 1000)} ms (generate {string(stream_us_gen / 1000)} ms, gather {string(stream_us_gather / 1000)} ms), {string(stream_walks)} stream-walks`)
}
# The slowest frames of the run, with the once-in-a-while CPU work that landed in them.
# An average never shows a hitch; this is the list of the frames you actually felt.
function prof_hitch_report() -> void {
if not prof_on or prof_dt == null or len(prof_dt) < 32 { return }
let n = len(prof_dt)
# median, for a sense of what "slow" means here
let sorted = new []long
var i = 8
while i < n { push(sorted, prof_dt[i]); i += 1 }
var a = 1
while a < len(sorted) {
let v = sorted[a]
var b = a - 1
while b >= 0 and sorted[b] > v { sorted[b + 1] = sorted[b]; b -= 1 }
sorted[b + 1] = v
a += 1
}
let med = sorted[len(sorted) / 2]
print("")
print(`the 20 slowest frames (median {string(med / 1000)}.{string((med / 100) % 10)} ms), and what was in them:`)
print(" frame dt cpu gpu-wait cover-gen game uploaded slowest CPU phase")
var shown = 0
var cut: long = sorted[len(sorted) - 1]
while shown < 20 and cut > med {
# the next slowest frame at or below `cut`
var best = -1
var bestv: long = -1
var k = 8
while k < n {
if prof_dt[k] <= cut and prof_dt[k] > bestv { bestv = prof_dt[k]; best = k }
k += 1
}
if best < 0 { return }
print(` {Text.pad_left(string(best), 6)} {Text.pad_left(string(prof_dt[best]), 6)}us {Text.pad_left(string(prof_cpu_us[best]), 7)}us {Text.pad_left(string(prof_dt[best] - prof_cpu_us[best]), 8)}us {Text.pad_left(string(prof_gen_us[best]), 7)}us {Text.pad_left(string(prof_game_us[best]), 7)}us {Text.pad_left(string(prof_up[best] / 1024), 7)}KB {Text.pad_right(prof_mark_who[best], 18)} {Text.pad_left(string(prof_mark_us[best]), 7)}us`)
cut = bestv - 1
shown += 1
}
}
function prof_gen_report() -> void {
if not prof_on { return }
if prof_gen == null or len(prof_gen) < 8 { return }
let sorted = new []int
var i = 8 # skip the first frames: one-off initial fill
while i < len(prof_gen) { push(sorted, prof_gen[i]); i += 1 }
var a = 1
while a < len(sorted) {
let v = sorted[a]
var b = a - 1
while b >= 0 and sorted[b] > v { sorted[b + 1] = sorted[b]; b -= 1 }
sorted[b + 1] = v
a += 1
}
let n = len(sorted)
var total = 0
var busy = 0
var j = 0
while j < n { total += sorted[j]; if sorted[j] > 0 { busy += 1 }; j += 1 }
print("")
print("ground cover generated per frame (the source of walking stutter):")
print(` frames that generated anything: {string(busy)} of {string(n)}`)
print(` median {string(sorted[n / 2])} p95 {string(sorted[(n * 95) / 100])} worst {string(sorted[n - 1])} total {string(total)}`)
}

View file

@ -0,0 +1,116 @@
# ============================================================================
# programs.ludic — GLSL from files. A shader file carries no #version line; the
# loader prepends "#version 410 core", any per-variant defines, and the shared
# noise.glsl + lighting.glsl chunks for fragment stages, then compiles it.
# ============================================================================
# Where the renderer's own files are. The shaders belong to this package and ship with
# it — in the Ludic checkout they are under packages/, and in an installed toolchain
# under $LUDIC_HOME/packages/ — so a game built outside the Ludic tree does not have to
# copy them in. The scanned CC0 materials are the other half: too large to ship with a
# toolchain and redistributable from their origin, so they are fetched into the project
# (`ludic assets`) and read from there.
var r3d_root: string = "packages/ludic.render3d" # where shaders/ lives
var r3d_assets: string = "assets/polyhaven" # where the CC0 assets live
var r3d_root_found: bool = false
# defines prepended to EVERY program (set before any is built): renderer-wide switches
var r3d_global_defs: string = ""
var r3d_noise_src: string = null
var r3d_lighting_src: string = null
function r3d_set_paths(root: string, assets: string) -> void { r3d_root = root; r3d_assets = assets }
# The install root, as the compiler computes it: $LUDIC_HOME, else the directory of the
# `ludic` on PATH. A game running from its own tree finds this package there.
function r3d_home() -> string {
let env = Os.env("LUDIC_HOME")
if env != null and env != "" { return env }
return `{Os.env("HOME")}/.ludic`
}
# Settle r3d_root on first use: the package as checked out beside the project, else the
# copy that ships with the toolchain.
function r3d_find_root() -> void {
if r3d_root_found { return }
r3d_root_found = true
if Fs.exists(`{r3d_root}/shaders/lighting.glsl`) { return }
let home = r3d_home()
let alt = `{home}/packages/ludic.render3d`
if Fs.exists(`{alt}/shaders/lighting.glsl`) { r3d_root = alt; return }
print(`r3d: cannot find the renderer's shaders (looked in {r3d_root}/shaders and {alt}/shaders)`)
}
function r3d_shader_file(name: string) -> string {
r3d_find_root()
let path = `{r3d_root}/shaders/{name}`
let s = Fs.read_text(path)
if s == null { print(`r3d: missing shader {path}`); return "" }
return s
}
function r3d_shader_src(name: string, defines: string, is_frag: bool) -> string {
if r3d_noise_src == null { r3d_noise_src = r3d_shader_file("noise.glsl") }
if r3d_lighting_src == null { r3d_lighting_src = r3d_shader_file("lighting.glsl") }
var s = "#version 410 core\n" + r3d_global_defs + defines
if is_frag { s = s + r3d_noise_src + r3d_lighting_src }
return s + r3d_shader_file(name)
}
# Build a program with tessellation control/evaluation between vertex and fragment.
function r3d_program_tess(vs: string, tcs: string, tes: string, fs: string, defines: string) -> int {
let p = gl_program5(r3d_shader_src(vs, defines, false), r3d_shader_src(tcs, defines, false),
r3d_shader_src(tes, defines, false), null, r3d_shader_src(fs, defines, true))
if p == 0 { print(`r3d: tess program failed: {vs} + {tcs} + {tes} + {fs}`) }
return p
}
# Build a program from a vertex + fragment file pair (defines apply to both).
function r3d_program(vs: string, fs: string, defines: string) -> int {
let p = gl_program(r3d_shader_src(vs, defines, false), r3d_shader_src(fs, defines, true))
if p == 0 { print(`r3d: program failed: {vs} + {fs}`) }
return p
}
# Bind a texture to a unit and point a sampler uniform at it.
function r3d_bind_tex(prog: int, name: string, unit: int, target: int, tex: int) -> void {
gl_active_texture(GL_TEXTURE0 + unit)
gl_bind_texture(target, tex)
gl_uniform1i(gl_get_uniform_location(prog, name), unit)
}
function r3d_bind_2d(prog: int, name: string, unit: int, tex: int) -> void { r3d_bind_tex(prog, name, unit, GL_TEXTURE_2D, tex) }
# A framebuffer with one colour texture (and optionally a depth texture).
property Target {
fbo: int = 0,
color: int = 0,
depth: int = 0,
w: int = 0,
h: int = 0
}
function target_new(w: int, h: int, ifmt: int, fmt: int, ty: int, with_depth: bool, filter: int) -> Target {
let t = new Target
t.w = w; t.h = h
t.fbo = gl_framebuffer()
gl_bind_framebuffer(GL_FRAMEBUFFER, t.fbo)
t.color = tex_target(w, h, ifmt, fmt, ty, filter)
gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, t.color, 0)
if with_depth {
t.depth = tex_target(w, h, GL_DEPTH_COMPONENT32F, GL_DEPTH_COMPONENT, GL_FLOAT, GL_NEAREST)
gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, t.depth, 0)
}
let st = gl_check_framebuffer_status(GL_FRAMEBUFFER)
if st != GL_FRAMEBUFFER_COMPLETE { print(`r3d: framebuffer incomplete {st} ({w}x{h})`) }
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
return t
}
function target_free(t: Target) -> void {
if t == null { return }
let ids = gl_scratch()
ids[0] = t.fbo; gl_delete_framebuffers(1, ids)
ids[0] = t.color; gl_delete_textures(1, ids)
if t.depth != 0 { ids[0] = t.depth; gl_delete_textures(1, ids) }
}
function target_bind(t: Target) -> void {
gl_bind_framebuffer(GL_FRAMEBUFFER, t.fbo)
gl_viewport(0, 0, t.w, t.h)
}

View file

@ -0,0 +1,95 @@
# ============================================================================
# quat.ludic — unit quaternions (x, y, z, w) as 4-word float-bit buffers, for
# skeletal poses. Same conventions as fmath.ludic: float bits in words, column-
# major matrices, o may alias its inputs unless stated.
# ============================================================================
function q_new() -> words { let q = words(4); q_identity(q); return q }
function q_identity(q: words) -> void { q[0] = F_ZERO; q[1] = F_ZERO; q[2] = F_ZERO; q[3] = F_ONE }
function q_set(q: words, x: int, y: int, z: int, w: int) -> void { q[0] = x; q[1] = y; q[2] = z; q[3] = w }
function q_copy(o: words, a: words) -> void { o[0] = a[0]; o[1] = a[1]; o[2] = a[2]; o[3] = a[3] }
# the idx-th quaternion of a packed buffer
function q_load(o: words, src: words, idx: int) -> void { for i in 0 .. 4 { o[i] = src[idx * 4 + i] } }
function q_store(dst: words, idx: int, a: words) -> void { for i in 0 .. 4 { dst[idx * 4 + i] = a[i] } }
# a rotation of `angle` radians about the unit axis (ax, ay, az)
function q_axis_angle(o: words, ax: int, ay: int, az: int, angle: int) -> void {
let h = f_mul(angle, F_HALF)
let s = f_sin(h)
o[0] = f_mul(ax, s); o[1] = f_mul(ay, s); o[2] = f_mul(az, s); o[3] = f_cos(h)
}
# o = a * b (apply b first, then a); o may alias a or b
function q_mul(o: words, a: words, b: words) -> void {
let ax = a[0]; let ay = a[1]; let az = a[2]; let aw = a[3]
let bx = b[0]; let by = b[1]; let bz = b[2]; let bw = b[3]
let x = f_sub(f_add(f_add(f_mul(aw, bx), f_mul(ax, bw)), f_mul(ay, bz)), f_mul(az, by))
let y = f_add(f_add(f_sub(f_mul(aw, by), f_mul(ax, bz)), f_mul(ay, bw)), f_mul(az, bx))
let z = f_add(f_sub(f_add(f_mul(aw, bz), f_mul(ax, by)), f_mul(ay, bx)), f_mul(az, bw))
let w = f_sub(f_sub(f_sub(f_mul(aw, bw), f_mul(ax, bx)), f_mul(ay, by)), f_mul(az, bz))
o[0] = x; o[1] = y; o[2] = z; o[3] = w
}
function q_conj(o: words, a: words) -> void { o[0] = f_neg(a[0]); o[1] = f_neg(a[1]); o[2] = f_neg(a[2]); o[3] = a[3] }
function q_normalize(q: words) -> void {
let l = f_sqrt(f_add(f_add(f_mul(q[0], q[0]), f_mul(q[1], q[1])), f_add(f_mul(q[2], q[2]), f_mul(q[3], q[3]))))
if l == 0 { q_identity(q); return }
let inv = f_div(F_ONE, l)
for i in 0 .. 4 { q[i] = f_mul(q[i], inv) }
}
# normalised linear blend from a to b (shortest arc), fine for the small steps a pose takes
function q_nlerp(o: words, a: words, b: words, t: int) -> void {
var d = f_add(f_add(f_mul(a[0], b[0]), f_mul(a[1], b[1])), f_add(f_mul(a[2], b[2]), f_mul(a[3], b[3])))
var sg = F_ONE
if f_ls(d, F_ZERO) { sg = f_neg(F_ONE) }
for i in 0 .. 4 { o[i] = f_lerp(a[i], f_mul(b[i], sg), t) }
q_normalize(o)
}
# rotate the vector v by q: o = q v q*
function q_rotate(o: words, q: words, v: words) -> void {
let qx = q[0]; let qy = q[1]; let qz = q[2]; let qw = q[3]
# t = 2 * cross(q.xyz, v)
let tx = f_mul(F_TWO, f_sub(f_mul(qy, v[2]), f_mul(qz, v[1])))
let ty = f_mul(F_TWO, f_sub(f_mul(qz, v[0]), f_mul(qx, v[2])))
let tz = f_mul(F_TWO, f_sub(f_mul(qx, v[1]), f_mul(qy, v[0])))
# o = v + w t + cross(q.xyz, t)
let x = f_add(f_add(v[0], f_mul(qw, tx)), f_sub(f_mul(qy, tz), f_mul(qz, ty)))
let y = f_add(f_add(v[1], f_mul(qw, ty)), f_sub(f_mul(qz, tx), f_mul(qx, tz)))
let z = f_add(f_add(v[2], f_mul(qw, tz)), f_sub(f_mul(qx, ty), f_mul(qy, tx)))
o[0] = x; o[1] = y; o[2] = z
}
# pitch about X, yaw about Y, roll about Z, composed as yaw * pitch * roll
var q_scratch: words = null
function q_euler(o: words, pitch: int, yaw: int, roll: int) -> void {
if q_scratch == null { q_scratch = words(16) }
let qx = q_scratch; let qy = mem_off(q_scratch, 16); let qz = mem_off(q_scratch, 32); let t = mem_off(q_scratch, 48)
q_axis_angle(qx, F_ONE, F_ZERO, F_ZERO, pitch)
q_axis_angle(qy, F_ZERO, F_ONE, F_ZERO, yaw)
q_axis_angle(qz, F_ZERO, F_ZERO, F_ONE, roll)
q_mul(t, qy, qx)
q_mul(o, t, qz)
}
# the rotation matrix of q (column-major, translation cleared)
function q_to_m4(m: words, q: words) -> void {
let x = q[0]; let y = q[1]; let z = q[2]; let w = q[3]
let xx = f_mul(x, x); let yy = f_mul(y, y); let zz = f_mul(z, z)
let xy = f_mul(x, y); let xz = f_mul(x, z); let yz = f_mul(y, z)
let wx = f_mul(w, x); let wy = f_mul(w, y); let wz = f_mul(w, z)
m[0] = f_sub(F_ONE, f_mul(F_TWO, f_add(yy, zz)))
m[1] = f_mul(F_TWO, f_add(xy, wz))
m[2] = f_mul(F_TWO, f_sub(xz, wy))
m[3] = F_ZERO
m[4] = f_mul(F_TWO, f_sub(xy, wz))
m[5] = f_sub(F_ONE, f_mul(F_TWO, f_add(xx, zz)))
m[6] = f_mul(F_TWO, f_add(yz, wx))
m[7] = F_ZERO
m[8] = f_mul(F_TWO, f_add(xz, wy))
m[9] = f_mul(F_TWO, f_sub(yz, wx))
m[10] = f_sub(F_ONE, f_mul(F_TWO, f_add(xx, yy)))
m[11] = F_ZERO
m[12] = F_ZERO; m[13] = F_ZERO; m[14] = F_ZERO; m[15] = F_ONE
}
# m = translate(t) * rotate(q) * scale(s)
function m4_trs_q(m: words, tx: int, ty: int, tz: int, q: words, sx: int, sy: int, sz: int) -> void {
q_to_m4(m, q)
for r in 0 .. 3 { m[r] = f_mul(m[r], sx); m[4 + r] = f_mul(m[4 + r], sy); m[8 + r] = f_mul(m[8 + r], sz) }
m[12] = tx; m[13] = ty; m[14] = tz
}

View file

@ -0,0 +1,26 @@
# ============================================================================
# ludic.render3d — a physically based 3D renderer on Gl.* (OpenGL 4.1 core).
# Import this one file; the game supplies scene_draw() / scene_draw_casters().
# ============================================================================
import "fmath.ludic"
import "prof.ludic"
import "programs.ludic"
import "texture.ludic"
import "mesh.ludic"
import "camera.ludic"
import "sky.ludic"
import "daylight.ludic"
import "terrain.ludic"
import "collide.ludic"
import "overlay.ludic"
import "shadow.ludic"
import "post.ludic"
import "quat.ludic"
import "gltf.ludic"
import "skin.ludic"
import "scatter.ludic"
import "actor.ludic"
import "stream.ludic"
import "grass.ludic"
import "water.ludic"
import "render.ludic"

View file

@ -0,0 +1,206 @@
# ============================================================================
# render.ludic — the frame. Shadow cascades, the HDR scene pass (terrain, the
# scene's objects, the sky), bloom, and the tonemapped composite to the screen.
# The scene (what the game places in the world) hooks in through scene_draw /
# scene_draw_casters, which the demo defines.
# ============================================================================
var r3d_sky_prog: int = 0
var r3d_fog_density: int = 0
var r3d_fog_falloff: int = 0
var r3d_time: int = 0
var r3d_ready: bool = false
# set before r3d_init to build the landscape from a real height map
var r3d_dem_path: string = null
var r3d_dem_min: int = 0
var r3d_dem_max: int = 0
var r3d_dem_base: int = 0
var r3d_dem_ox: int = 0
var r3d_dem_oz: int = 0
var r3d_ortho_path: string = null
var r3d_debug: bool = false
var r3d_debug_shadow: bool = false
var r3d_debug_max: bool = false
var r3d_cloud_shadow: int = 0x3F000000 # 0.5
var r3d_clip_y: int = 0xCF000000 # -2^31: no clipping
function fog_bind(prog: int) -> void {
u_f(gl_uniform(prog, "u_clip_y"), r3d_clip_y)
u_f(gl_uniform(prog, "u_spec_scale"), F_ONE)
u_f(gl_uniform(prog, "u_fog_density"), r3d_fog_density)
u_f(gl_uniform(prog, "u_fog_height_falloff"), r3d_fog_falloff)
var cs = r3d_cloud_shadow
if Os.has_env("R3D_NOCLOUD") { cs = F_ZERO }
u_f(gl_uniform(prog, "u_cloud_shadow"), cs)
u_f(gl_uniform(prog, "u_time"), r3d_time)
}
# profiling switches (environment): R3D_NOSHADOW R3D_NOGI R3D_MSAA=n R3D_NOBLADES R3D_NOCARDS R3D_NOTREES R3D_NEAR=m
var r3d_test_frame: int = 0
var r3d_test_resize: int = 0
var r3d_no_shadow: bool = false
var r3d_no_trees: bool = false
var r3d_no_refl: bool = false
function r3d_env_flags() -> void {
r3d_no_shadow = Os.has_env("R3D_NOSHADOW")
r3d_no_trees = Os.has_env("R3D_NOTREES")
r3d_no_refl = Os.has_env("R3D_NOREFL")
if Os.has_env("R3D_DEBUG") { r3d_debug = true }
if Os.has_env("R3D_DBGSHADOW") { r3d_debug_shadow = true }
if Os.has_env("R3D_NOGI") { post_gi_strength = F_ZERO; post_ao_strength = F_ZERO; post_no_gi = true }
if Os.has_env("R3D_MSAA") { post_ms_samples = Text.to_int(Os.env("R3D_MSAA")) }
sc_skip_blade = Os.has_env("R3D_NOBLADES")
sc_skip_card = Os.has_env("R3D_NOCARDS")
sc_dbg_lod = Os.has_env("R3D_LODDBG")
if Os.has_env("R3D_ANISO") {
let a = Text.to_int(Os.env("R3D_ANISO"))
tex_anisotropy = 1.0
if a >= 2 { tex_anisotropy = 2.0 }
if a >= 4 { tex_anisotropy = 4.0 }
if a >= 8 { tex_anisotropy = 8.0 }
if a >= 16 { tex_anisotropy = 16.0 }
}
}
function r3d_init(w: int, h: int, title: string) -> bool {
r3d_env_flags()
if not gl_open(w, h, title) { print("r3d: no OpenGL context"); return false }
if Os.has_env("R3D_NOVSYNC") { gl_vsync(0) }
var renderer: string = gl_get_string(GL_RENDERER)
print(`r3d: {gl_w}x{gl_h} on {renderer}`)
prof_init()
cam_init(fr(gl_w, gl_h))
if not sky_load(r3d_assets + "/hdri/kloofendal_48d_partly_cloudy_puresky_4k.hdr") { return false }
daylight_init()
if r3d_dem_path != null { terrain_use_dem(r3d_dem_path, r3d_dem_min, r3d_dem_max, r3d_dem_base, r3d_dem_ox, r3d_dem_oz) }
if r3d_ortho_path != null { terrain_use_ortho(r3d_ortho_path) }
terrain_init()
shadow_init()
post_init(gl_w, gl_h)
scatter_init()
actor_init()
grass_init()
r3d_sky_prog = r3d_program("fullscreen.vert", "sky.frag", "")
r3d_fog_density = fl(0.00014)
r3d_fog_falloff = fl(0.002)
r3d_ready = true
gl_check("r3d init")
return true
}
function r3d_draw_sky() -> void {
gl_depth_func(GL_LEQUAL)
gl_depth_mask(0)
gl_disable(GL_CULL_FACE)
let p = r3d_sky_prog
gl_use_program(p)
r3d_bind_2d(p, "u_sky", 0, sky_tex)
sky_bind_rot(p)
sky_bind_lighting(p)
u_mat4(gl_uniform(p, "u_inv_vp"), cam_inv_vp)
u_f(gl_uniform(p, "u_sky_gain"), fl(0.95))
u_f(gl_uniform(p, "u_sky_sat"), fl(1.35))
u_f(gl_uniform(p, "u_time"), r3d_time)
mesh_draw(sky_fullscreen)
gl_depth_mask(1)
gl_depth_func(GL_LESS)
}
# the drawable changed size: the camera's aspect and every screen-sized target follow
function r3d_resize() -> void {
cam_aspect = fr(gl_w, gl_h)
cam_update()
post_free()
post_init(gl_w, gl_h)
if water_refl != null { target_free(water_refl); water_refl = null }
print(`r3d: resized to {gl_w}x{gl_h}`)
}
function r3d_frame(time: int) -> void {
if not r3d_ready { return }
if gl_resize_check() { r3d_resize() }
# R3D_RESIZE_AT=<frame>: rebuild every screen-sized buffer mid-run, as a window resize
# or a fullscreen change does. Headless has no window to resize, and this path is where
# a stale attachment or a texture freed twice shows up.
r3d_test_frame += 1
if r3d_test_resize == 0 and Os.has_env("R3D_RESIZE_AT") { r3d_test_resize = Text.to_int(Os.env("R3D_RESIZE_AT")) }
# R3D_RESIZE_AT=<n>: from frame n on, rebuild every screen-sized buffer every few
# frames at a different size, as dragging a window edge or entering fullscreen does.
if r3d_test_resize > 0 and r3d_test_frame >= r3d_test_resize and r3d_test_frame % 4 == 0 {
let step = (r3d_test_frame / 4) % 4
var w = 1920; var h = 1080
if step == 1 { w = 1440; h = 810 }
if step == 2 { w = 2560; h = 1440 }
if step == 3 { w = 1281; h = 721 }
gl_w = w; gl_h = h
r3d_resize()
}
r3d_time = time
# the frame's counters close here, before any of its own work: the window each of them
# covers is exactly one frame, from this point to the same point next time
prof_gen_frame()
prof_mark_start()
cam_begin_frame(post_frame, gl_w, gl_h)
# the height-field shadow rebakes as the light moves in steps (daylight), or with the sky yaw when there is no clock
if (not day_on and ter_shadow_yaw != sky_yaw) or ter_shadow_gen != day_gen {
ter_shadow_gen = day_gen
let t_bk = gl_now_us()
terrain_bake_shadow()
prof_bake_add(gl_now_us() - t_bk)
}
scatter_begin_frame()
prof_cpu_mark("shadow rebake")
stream_update_all()
prof_cpu_mark("streaming")
if not r3d_no_shadow { prof_begin("shadow"); shadow_pass(); prof_end() }
prof_cpu_mark("shadow pass")
if water_on and not r3d_no_refl { prof_begin("water reflection"); water_reflection_pass(); prof_end() }
prof_cpu_mark("reflection")
post_begin_scene()
prof_begin("terrain sun")
terrain_sun_prepare()
prof_end()
prof_begin("terrain")
terrain_draw()
prof_end()
prof_cpu_mark("terrain")
prof_begin("scene (vegetation)")
scene_draw()
prof_end()
prof_cpu_mark("vegetation")
prof_begin("grass")
grass_draw()
prof_end()
prof_cpu_mark("grass")
prof_begin("sky")
r3d_draw_sky()
prof_end()
prof_begin("resolve MSAA")
post_resolve()
prof_end()
# transparent water over the resolved frame: it tests against the frame's own
# depth and reads a copy of it for the depth tint and soft shores
if water_on {
prof_begin("water surface")
post_capture_scene()
target_bind(post_hdr)
gl_enable(GL_DEPTH_TEST)
gl_depth_func(GL_LESS)
water_draw(post_depth_copy.depth)
prof_end()
}
post_color = post_hdr.color
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")
post_bloom_pass()
prof_end()
prof_begin("tonemap+exposure")
post_tonemap(post_color)
prof_end()
prof_cpu_mark("post")
prof_begin("prev-colour copy")
post_capture_prev()
prof_end()
prof_collect()
gl_check("frame")
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
// auto-exposure on the GPU: the scene's mean luminance from the top of its mip chain,
// eased toward from the previous frame's value, written to a 1x1 texture the tonemapper
// reads. Nothing comes back to the CPU (a readback there waited for the whole frame's
// GPU work and serialised the two: 34 ms -> the sum of both, measured 2026-09-09).
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_scene;
uniform sampler2D u_prev;
uniform float u_lod;
uniform float u_key;
uniform float u_max;
uniform float u_rate;
uniform float u_reset;
void main() {
vec3 c = textureLod(u_scene, vec2(0.5), u_lod).rgb;
c = clamp(c, vec3(0.0), vec3(1.0e5));
float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
float target = clamp(u_key / max(lum, 0.001), 0.02, u_max);
float prev = texture(u_prev, vec2(0.5)).r;
float e = mix(prev, target, u_rate);
if (u_reset > 0.5 || !(prev > 0.0)) e = target;
o_color = vec4(e, 0.0, 0.0, 1.0);
}

View file

@ -0,0 +1,29 @@
// impostor bake: albedo + coverage, and the model-frame normal
in vec3 v_wpos;
in vec3 v_nrm;
in vec2 v_uv;
in float v_seed;
layout(location = 0) out vec4 o_albedo;
layout(location = 1) out vec4 o_normal;
uniform sampler2D u_diff;
uniform sampler2D u_arm;
void main() {
vec4 d = texture(u_diff, v_uv);
if (d.a < 0.5) discard; // cut-out cards (needles, blades, leaves) bake with their shape
vec3 N = normalize(v_nrm);
if (!gl_FrontFacing) N = -N;
#ifdef FLOWER
float vy = clamp(v_uv.y, 0.0, 1.0);
if (v_uv.x >= 2.0) d.rgb = vec3(0.035, 0.09, 0.02) * (0.7 + 0.6 * vy);
else if (v_uv.x >= 1.0) {
float f = fract(v_uv.x);
vec3 violet = vec3(0.06, 0.03, 0.3);
vec3 lip = vec3(0.4, 0.3, 0.68);
// each floret: a dark keel at the base, a pale standard at the top edge
d.rgb = mix(violet, lip, smoothstep(0.35, 1.0, vy) * 0.6 + 0.25 * smoothstep(0.3, 0.0, abs(f - 0.5)));
d.rgb *= 0.85 + 0.15 * vy;
} else d.rgb = vec3(0.08, 0.17, 0.03);
#endif
o_albedo = vec4(d.rgb, 1.0);
o_normal = vec4(N * 0.5 + 0.5, texture(u_arm, v_uv).r);
}

View file

@ -0,0 +1,27 @@
// 13-tap downsample (Jimenez), with a soft threshold on the first level
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_src;
uniform vec2 u_texel;
uniform float u_threshold; // <0: no threshold
void main() {
vec2 t = u_texel;
vec3 a = texture(u_src, v_uv + t * vec2(-2, 2)).rgb, b = texture(u_src, v_uv + t * vec2(0, 2)).rgb, c = texture(u_src, v_uv + t * vec2(2, 2)).rgb;
vec3 d = texture(u_src, v_uv + t * vec2(-2, 0)).rgb, e = texture(u_src, v_uv).rgb, f = texture(u_src, v_uv + t * vec2(2, 0)).rgb;
vec3 g = texture(u_src, v_uv + t * vec2(-2, -2)).rgb, h = texture(u_src, v_uv + t * vec2(0, -2)).rgb, i = texture(u_src, v_uv + t * vec2(2, -2)).rgb;
vec3 j = texture(u_src, v_uv + t * vec2(-1, 1)).rgb, k = texture(u_src, v_uv + t * vec2(1, 1)).rgb;
vec3 l = texture(u_src, v_uv + t * vec2(-1, -1)).rgb, m = texture(u_src, v_uv + t * vec2(1, -1)).rgb;
a = min(a, vec3(4096.0)); b = min(b, vec3(4096.0)); c = min(c, vec3(4096.0)); d = min(d, vec3(4096.0)); e = min(e, vec3(4096.0));
f = min(f, vec3(4096.0)); g = min(g, vec3(4096.0)); h = min(h, vec3(4096.0)); i = min(i, vec3(4096.0)); j = min(j, vec3(4096.0));
k = min(k, vec3(4096.0)); l = min(l, vec3(4096.0)); m = min(m, vec3(4096.0));
vec3 col = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125;
if (u_threshold >= 0.0) {
float br = max(col.r, max(col.g, col.b));
float knee = u_threshold * 0.5;
float soft = clamp(br - u_threshold + knee, 0.0, 2.0 * knee);
soft = soft * soft / (4.0 * knee + 1e-4);
float contrib = max(soft, br - u_threshold) / max(br, 1e-4);
col *= contrib;
}
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,13 @@
// 3x3 tent upsample, added onto the destination (blend ONE ONE)
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_src;
uniform vec2 u_texel;
uniform float u_radius;
void main() {
vec2 t = u_texel * u_radius;
vec3 s = texture(u_src, v_uv + t * vec2(-1, 1)).rgb + texture(u_src, v_uv + t * vec2(0, 1)).rgb * 2.0 + texture(u_src, v_uv + t * vec2(1, 1)).rgb
+ texture(u_src, v_uv + t * vec2(-1, 0)).rgb * 2.0 + texture(u_src, v_uv).rgb * 4.0 + texture(u_src, v_uv + t * vec2(1, 0)).rgb * 2.0
+ texture(u_src, v_uv + t * vec2(-1, -1)).rgb + texture(u_src, v_uv + t * vec2(0, -1)).rgb * 2.0 + texture(u_src, v_uv + t * vec2(1, -1)).rgb;
o_color = vec4(s / 16.0, 1.0);
}

View file

@ -0,0 +1,7 @@
// full-screen triangle from gl_VertexID; uv in [0,1], z = 1 (the far plane)
out vec2 v_uv;
void main() {
vec2 p = vec2((gl_VertexID == 1) ? 3.0 : -1.0, (gl_VertexID == 2) ? 3.0 : -1.0);
v_uv = p * 0.5 + 0.5;
gl_Position = vec4(p, 1.0, 1.0);
}

View file

@ -0,0 +1,149 @@
// Procedural ground-cover blades, generated on the GPU, with NO distance rings.
//
// The world is cut into 16 m cells. Blade j of a cell always stands at the same place
// (a hash of the cell and j), so a blade never moves. How many of a cell's blades exist
// is a smooth function of the blade's own distance to the camera: cell area over the
// square of a spacing that grows linearly with distance. Thinning removes the highest
// indices first, and a blade shrinks before it goes, so density is continuous in space
// and in time and nothing can form a boundary. Draws are per tile (CPU-side frustum
// culling, grass.ludic); a tile only decides how many indices to feed the shader.
layout(location = 0) in vec3 a_pos; // x: -0.5..0.5 across, y: 0..1 along the blade, z: bend
layout(location = 2) in vec2 a_uv;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_vp;
uniform vec3 u_cam_pos;
uniform float u_time;
uniform sampler2D u_ts_height;
uniform vec2 u_ts_origin;
uniform float u_ts_half;
uniform sampler2D u_ortho;
uniform float u_ortho_on;
uniform float u_lake_level;
uniform float u_snow_line;
uniform float u_wind;
uniform vec2 u_tile; // world xz of this tile's corner
uniform int u_tile_cells; // 16 m cells per tile side
uniform int u_per_cell; // indices drawn per cell in this tile
uniform float u_s0; // blade spacing at the camera (m)
uniform float u_d0; // distance at which the spacing has doubled (m)
uniform float u_radius; // no blades past this
uniform int u_dbg; // R3D_GRASS_DBG: 1 lift blades 0.3 m, 2 light as ground everywhere, 3 both
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
out float v_hull;
const float CELL = 16.0;
float hash1(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
// An integer hash (PCG) for the per-blade values. The sine hash advanced linearly with
// the blade index, so a cell's blades fell into diagonal rows, and the rows read as
// streaks across the meadow with an edge wherever they thinned out.
uint pcg(uint v) { uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; }
float bladeHash(ivec2 cell, int j, int k) {
uint h = pcg(uint(cell.x + 32768) * 73856093u ^ uint(cell.y + 32768) * 19349663u ^ uint(j) * 83492791u ^ uint(k) * 2654435761u);
return float(h) * (1.0 / 4294967295.0);
}
float heightSmooth(sampler2D tex, vec2 uv) {
vec2 res = vec2(textureSize(tex, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0;
vec2 w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
}
void cull() { gl_Position = vec4(0.0, 0.0, 2.0, 1.0); v_wpos = vec3(0.0); v_nrm = vec3(0.0, 1.0, 0.0); v_uv = vec2(0.0); v_seed = 0.0; v_rot = vec2(0.0, 1.0); v_hull = 1.0; }
void main() {
int i = gl_InstanceID;
int c = i / u_per_cell;
int j = i - c * u_per_cell;
vec2 cell = u_tile + vec2(float(c % u_tile_cells), float(c / u_tile_cells)) * CELL;
vec2 cid = floor(cell / CELL + 0.5);
ivec2 ci = ivec2(cid);
float fj = float(j);
// this blade's fixed place in its cell
vec2 hv = vec2(bladeHash(ci, j, 0), bladeHash(ci, j, 1));
vec2 xz = cell + hv * CELL;
vec2 d2 = xz - u_cam_pos.xz;
float dist = length(d2);
if (dist >= u_radius) { cull(); return; }
// how many of this cell's blades exist at this distance: area over spacing^2, spacing
// growing linearly with distance. j beyond that count does not exist; the last fifth
// of the count shrinks to nothing so a blade never pops.
float spacing = u_s0 * (1.0 + dist / u_d0);
float count = CELL * CELL / (spacing * spacing) * (1.0 - smoothstep(u_radius * 0.7, u_radius, dist));
if (fj >= count) { cull(); return; }
float life = 1.0 - smoothstep(0.8, 1.0, fj / max(count, 1.0));
// the ground under it
vec2 huv = (xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
if (huv.x < 0.0 || huv.x > 1.0 || huv.y < 0.0 || huv.y > 1.0) { cull(); return; }
vec4 ht = texture(u_ts_height, huv);
vec4 croot = u_vp * vec4(xz.x, ht.r, xz.y, 1.0);
if (croot.w < -1.0 || abs(croot.x) > croot.w * 1.25 + 1.5 || abs(croot.y) > croot.w * 1.4 + 1.5) { cull(); return; }
vec3 gn = normalize(ht.gba);
float h3 = bladeHash(ci, j, 2), h4 = bladeHash(ci, j, 3);
float ok = (1.0 - smoothstep(0.30, 0.55, 1.0 - gn.y)) * smoothstep(0.0, 0.6, ht.r - u_lake_level - 0.15) * smoothstep(u_snow_line - 80.0, u_snow_line - 200.0, ht.r);
if (u_ortho_on > 0.5) {
vec3 oc = textureLod(u_ortho, huv, 1.5).rgb;
ok *= 0.25 + 0.75 * smoothstep(0.0, 0.02, oc.g - max(oc.r, oc.b));
}
if (h4 > ok) { cull(); return; }
// the root on the drawn surface: the CDLOD mesh follows the B-spline to within
// centimetres near the camera, so the smooth sample is the drawn height
bool far = dist > 300.0;
float h = far ? ht.r : heightSmooth(u_ts_height, huv);
if (far) h += 0.03;
if ((u_dbg & 1) != 0) h += 0.3;
// the blade: sized so that coverage stays level as the spacing grows
float seed = hv.x * 0.7 + hv.y * 0.3;
float ang = hv.y * 6.2831853;
float s = sin(ang), c_ = cos(ang);
float grow = spacing / u_s0; // 1 at the camera, growing with distance
float tall = mix(0.18, 0.42, h3) * mix(0.8, 1.2, hash1(cid * 0.1)) * (1.0 + 0.35 * smoothstep(1.0, 12.0, grow)) * life;
float bw = 0.028 * mix(1.0, 0.45 * grow, smoothstep(1.0, 4.0, grow));
if (far) { bw = max(bw, spacing * 0.35); tall = min(tall, spacing * 0.3); }
vec3 p = vec3(a_pos.x * bw, a_pos.y * tall, a_pos.z * tall * (0.6 + 0.8 * h4));
vec3 n = vec3(0.0, 0.3, 1.0);
float gust = sin(xz.x * 0.09 + u_time * 1.1) * 0.5 + sin(xz.y * 0.13 - u_time * 0.8 + xz.x * 0.05) * 0.5;
float ph = u_time * 1.7 + seed * 6.2831 + xz.x * 0.05 + xz.y * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4 + gust) * u_wind;
float hgt = max(p.y, 0.0);
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);
p = vec3(c_ * p.x + s * p.z, p.y, -s * p.x + c_ * p.z);
n = normalize(vec3(c_ * n.x + s * n.z, n.y, -s * n.x + c_ * n.z));
// stand on the ground: rotate the blade's frame from world-up to the surface normal
{
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 k = cross(up, gn);
float sk = length(k), ck = gn.y;
if (sk > 1e-4) {
k /= sk;
p = p * ck + cross(k, p) * sk + k * dot(k, p) * (1.0 - ck);
n = normalize(n * ck + cross(k, n) * sk + k * dot(k, n) * (1.0 - ck));
}
}
// Blades are lit with the ground's normal from a few metres out. Lit by their own
// facing, the wind's sine field bent them in bands and the lit/unlit sides flipped in
// those bands: light and dark rows across the whole meadow. Ground-normal lighting is
// what open-world grass does (the blade's own normal only matters within arm's reach).
n = normalize(mix(n, gn, smoothstep(2.0, 12.0, dist)));
vec3 w = vec3(xz.x, h - 0.02, xz.y) + p;
v_wpos = w;
v_nrm = n;
v_uv = far ? vec2(a_uv.x, 0.45 + 0.2 * a_uv.y) : a_uv;
v_seed = seed;
v_rot = vec2(s, c_);
v_hull = (dist > 2.0 || far || (u_dbg & 2) != 0) ? -1.0 : 1.0; // no back-face flip, no rounding past arm's reach
gl_Position = u_proj * u_view * vec4(w, 1.0);
}

View file

@ -0,0 +1,110 @@
// world height (metres) for the texel's x/z; R32F target
in vec2 v_uv;
out vec4 o;
uniform float u_half;
#ifdef DEM
uniform sampler2D u_dem; // 16-bit height map of a real place (Copernicus GLO-30)
uniform float u_dem_min;
uniform float u_dem_max;
uniform float u_dem_base; // the elevation that becomes y = 0
uniform vec2 u_origin; // world x/z of the map's centre
uniform vec4 u_lake; // a lake: centre x/z, half extents (zero = none)
uniform float u_lake_level; // its surface height; the model records the surface, the bed is carved below it
// The DEM is Copernicus GLO-30 — 30 m data resampled onto this 4 m grid — so the stored
// field is piecewise linear with a slope discontinuity every ~7 texels. Differencing it
// for a shading normal turns each kink into a ridge, and on steep ground, where the same
// kink spans a large height change, they read as a regular corrugation running across
// the slope. Smoothing across that lattice removes them and discards no real detail:
// there is none below 30 m in the source, and the fractal detail below supplies the fine
// relief. Kernel is a separable gaussian sampled at 3-texel spacing (~12 m each side).
float demRaw(vec2 uv) { return texture(u_dem, uv).r; }
// u_dem_blur texels of separable gaussian (0 = the survey as it is). The 30 m Copernicus
// model needed ~3 texels to hide its resampling lattice; 2 m lidar needs none.
uniform float u_dem_blur;
float demH(vec2 uv) {
if (u_dem_blur <= 0.0) return mix(u_dem_min, u_dem_max, demRaw(uv));
vec2 t = u_dem_blur / vec2(textureSize(u_dem, 0));
float c = demRaw(uv);
float e = demRaw(uv + vec2(t.x, 0.0)) + demRaw(uv - vec2(t.x, 0.0))
+ demRaw(uv + vec2(0.0, t.y)) + demRaw(uv - vec2(0.0, t.y));
float d = demRaw(uv + t) + demRaw(uv - t)
+ demRaw(uv + vec2(t.x, -t.y)) + demRaw(uv + vec2(-t.x, t.y));
float f = demRaw(uv + 2.0 * vec2(t.x, 0.0)) + demRaw(uv - 2.0 * vec2(t.x, 0.0))
+ demRaw(uv + 2.0 * vec2(0.0, t.y)) + demRaw(uv - 2.0 * vec2(0.0, t.y));
float h = (12.0 * c + 6.0 * e + 3.0 * d + 2.0 * f) / (12.0 + 24.0 + 12.0 + 8.0);
return mix(u_dem_min, u_dem_max, h);
}
#endif
float bump(vec2 p, vec2 c, float r) { float d = length(p - c) / r; return exp(-d * d * 2.0); }
void main() {
#ifdef DEM
vec2 xz = (v_uv - 0.5) * 2.0 * u_half + u_origin;
float e = 1.0 / 2048.0;
float h = demH(v_uv) - u_dem_base;
// the basin mask keys off the UNSMOOTHED sample: the lake is only ~1 m below its shore
// in the model, and the 12 m blur above averages the shore with the flat water beside
// it, dragging it under the outline threshold — the camera's own bank was carved 7 m down
float raw = demRaw(v_uv) * (u_dem_max - u_dem_min) + u_dem_min - u_dem_base;
// the survey carries its own relief at this resolution; only the foot track is added
h -= 0.25 * smoothstep(6.0, 1.5, pathDist(xz));
if (u_lake.z > 0.0) {
// the elevation model samples the water surface as flat ground: inside the lake's
// outline sink it into a bed, deepest in the middle, with a gentle gravel ramp at the shore
vec2 q = (xz - u_lake.xy) / u_lake.zw;
float inside = smoothstep(1.0, 0.8, dot(q, q));
// the lidar records the lake as a flat surface exactly at its level: everything at or
// just above that level inside the outline is lake bed
float basin = smoothstep(u_lake_level + 0.6, u_lake_level - 0.3, raw) * inside;
float bed = u_lake_level - 0.4 - (5.0 + 3.0 * (1.0 - dot(q, q)) + 1.5 * fbm(xz * 0.02, 3)) * basin * basin; // a gentle gravel ramp, then the drop
h = mix(h, bed, basin);
}
o = vec4(h, 0.0, 0.0, 1.0);
#elif defined(SMOOTH)
// A purpose-built test ground: 2 km square, analytically smooth everywhere. No survey
// data, so no resampling lattice and no quantised source — if a grid still shows on
// this, the cause is in the renderer rather than the elevation model.
vec2 xz = (v_uv - 0.5) * 2.0 * u_half;
float d = length(xz);
// meadow: a gentle roll a few metres either side of 8 m, two octaves only
float h = 8.0 + 4.5 * fbm(xz * 0.0018, 3) + 1.4 * fbm(xz * 0.007, 3);
// a small pond in the middle: a smooth basin ~90 m across, floor about 5 m down
float pond = exp(-dot(xz, xz) / (2.0 * 70.0 * 70.0));
h -= 13.0 * pond;
// the rim mountain, 200 m above the meadow, starting well outside the grass
h += smoothstep(620.0, 980.0, d) * (200.0 + 90.0 * fbm(xz * 0.0025 + 4.0, 4));
o = vec4(h, 0.0, 0.0, 1.0);
#else
vec2 xz = (v_uv - 0.5) * 2.0 * u_half;
float d = length(xz);
// the meadow falls away northward (-z) from the rise the camera stands on, into a broad valley
float base = 0.055 * xz.y + 4.0 * fbm(xz * 0.008, 5) + 1.2 * fbm(xz * 0.04, 4) + 0.15 * fbm(xz * 0.35, 3);
// soft valley floor with a stream
float floorY = -22.0;
float k = 12.0;
base = floorY + k * log(1.0 + exp((base - floorY) / k));
float sb = smoothstep(6.0, 0.0, abs(xz.x - 150.0 - 90.0 * sin(xz.y * 0.006 + 1.0))) * smoothstep(-220.0, -400.0, xz.y);
base -= 1.6 * sb;
// the track sits slightly worn in
base -= 0.25 * smoothstep(6.0, 1.5, pathDist(xz));
float h = base;
// far ridge across the valley, forested hills
float back = smoothstep(-550.0, -1000.0, xz.y);
h += back * (90.0 + 160.0 * ridged(xz * 0.0025 + 5.0, 6));
// the great snow mountain to the north-west, and its shoulder
float m1 = bump(xz, vec2(-820.0, -520.0), 520.0);
float m2 = bump(xz, vec2(-1150.0, -900.0), 600.0);
float m3 = bump(xz, vec2(-560.0, -150.0), 260.0);
float mtn = max(m1 * 620.0, max(m2 * 720.0, m3 * 180.0));
h += mtn * (0.45 + 0.55 * ridged(xz * 0.0018 + 11.0, 7)) + 40.0 * (m1 + m2) * ridged(xz * 0.008 + 3.0, 5);
// hills to the east, lower and rounder
float e1 = bump(xz, vec2(760.0, -420.0), 420.0);
float e2 = bump(xz, vec2(950.0, 150.0), 380.0);
h += (e1 * 170.0 + e2 * 120.0) * (0.6 + 0.4 * fbm(xz * 0.004 + 9.0, 5));
// the outer rim so nothing ends at a flat edge
h += smoothstep(750.0, 1024.0, d) * (120.0 + 120.0 * ridged(xz * 0.003, 5));
// a rocky knoll on the right of the meadow
float knoll = 14.0 * bump(xz, vec2(230.0, -40.0), 70.0);
h += knoll * (0.6 + 0.4 * fbm(xz * 0.05, 4));
o = vec4(h, 0.0, 0.0, 1.0);
#endif
}

View file

@ -0,0 +1,36 @@
in vec2 v_uv;
out vec4 o_color;
vec2 hammersley(uint i, uint n) {
uint b = i;
b = (b << 16u) | (b >> 16u);
b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
return vec2(float(i) / float(n), float(b) * 2.3283064365386963e-10);
}
void main() {
float NoV = max(v_uv.x, 1e-3);
float rough = max(v_uv.y, 0.02);
vec3 v = vec3(sqrt(1.0 - NoV * NoV), 0.0, NoV);
float a = rough * rough;
float A = 0.0, B = 0.0;
const uint N = 512u;
for (uint i = 0u; i < N; i++) {
vec2 x = hammersley(i, N);
float phi = 2.0 * PI * x.x;
float ct = sqrt((1.0 - x.y) / (1.0 + (a * a - 1.0) * x.y));
float st = sqrt(1.0 - ct * ct);
vec3 h = vec3(cos(phi) * st, sin(phi) * st, ct);
vec3 l = 2.0 * dot(v, h) * h - v;
float NoL = max(l.z, 0.0), NoH = max(h.z, 0.0), VoH = max(dot(v, h), 0.0);
if (NoL > 0.0) {
float G = V_Smith(NoV, NoL, a) * 4.0 * NoL * NoV; // Smith G from the visibility term
float Gv = G * VoH / max(NoH * NoV, 1e-4);
float Fc = pow(1.0 - VoH, 5.0);
A += (1.0 - Fc) * Gv;
B += Fc * Gv;
}
}
o_color = vec4(clamp(A / float(N), 0.0, 1.0), clamp(B / float(N), 0.0, 1.0), 0.0, 1.0);
}

View file

@ -0,0 +1,36 @@
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_sky;
uniform float u_sun_clip; // clamp the sun's radiance so it does not alias the convolution
vec3 dirFromUV(vec2 uv) {
float phi = (uv.x - 0.5) * 2.0 * PI;
float theta = uv.y * PI;
return vec3(sin(theta) * sin(phi), cos(theta), -sin(theta) * cos(phi));
}
vec2 hammersley(uint i, uint n) {
uint b = i;
b = (b << 16u) | (b >> 16u);
b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
return vec2(float(i) / float(n), float(b) * 2.3283064365386963e-10);
}
void main() {
vec3 n = dirFromUV(v_uv);
vec3 up = abs(n.y) < 0.999 ? vec3(0, 1, 0) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, n));
vec3 b = cross(n, t);
vec3 acc = vec3(0.0);
const uint N = 512u;
for (uint i = 0u; i < N; i++) {
vec2 h = hammersley(i, N);
float phi = 2.0 * PI * h.x;
float ct = sqrt(1.0 - h.y); // cosine-weighted
float st = sqrt(h.y);
vec3 d = t * (cos(phi) * st) + b * (sin(phi) * st) + n * ct;
vec3 c = textureLod(u_sky, skyUV(d), 5.0).rgb;
acc += min(c, vec3(u_sun_clip));
}
o_color = vec4(acc / float(N), 1.0);
}

View file

@ -0,0 +1,53 @@
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_sky;
uniform float u_rough;
uniform float u_sun_clip;
uniform float u_sky_w;
vec3 dirFromUV(vec2 uv) {
float phi = (uv.x - 0.5) * 2.0 * PI;
float theta = uv.y * PI;
return vec3(sin(theta) * sin(phi), cos(theta), -sin(theta) * cos(phi));
}
vec2 hammersley(uint i, uint n) {
uint b = i;
b = (b << 16u) | (b >> 16u);
b = ((b & 0x55555555u) << 1u) | ((b & 0xAAAAAAAAu) >> 1u);
b = ((b & 0x33333333u) << 2u) | ((b & 0xCCCCCCCCu) >> 2u);
b = ((b & 0x0F0F0F0Fu) << 4u) | ((b & 0xF0F0F0F0u) >> 4u);
b = ((b & 0x00FF00FFu) << 8u) | ((b & 0xFF00FF00u) >> 8u);
return vec2(float(i) / float(n), float(b) * 2.3283064365386963e-10);
}
void main() {
vec3 n = dirFromUV(v_uv);
vec3 v = n;
if (u_rough < 0.02) { o_color = vec4(textureLod(u_sky, skyUV(n), 0.0).rgb, 1.0); return; }
vec3 up = abs(n.y) < 0.999 ? vec3(0, 1, 0) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, n));
vec3 b = cross(n, t);
float a = u_rough * u_rough;
vec3 acc = vec3(0.0);
float wsum = 0.0;
const uint N = 256u;
for (uint i = 0u; i < N; i++) {
vec2 x = hammersley(i, N);
float phi = 2.0 * PI * x.x;
float ct = sqrt((1.0 - x.y) / (1.0 + (a * a - 1.0) * x.y));
float st = sqrt(1.0 - ct * ct);
vec3 h = t * (cos(phi) * st) + b * (sin(phi) * st) + n * ct;
vec3 l = 2.0 * dot(v, h) * h - v;
float NoL = dot(n, l);
if (NoL > 0.0) {
float NoH = max(ct, 0.0);
float D = D_GGX(NoH, a);
float pdf = D * NoH / (4.0 * max(dot(v, h), 1e-4)) + 1e-4;
float saTexel = 4.0 * PI / (u_sky_w * u_sky_w * 0.5);
float saSample = 1.0 / (float(N) * pdf);
float mip = clamp(0.5 * log2(saSample / saTexel) + 1.0, 0.0, 8.0);
vec3 c = textureLod(u_sky, skyUV(l), mip).rgb;
acc += min(c, vec3(u_sun_clip)) * NoL;
wsum += NoL;
}
}
o_color = vec4(acc / max(wsum, 1e-4), 1.0);
}

View file

@ -0,0 +1,66 @@
in vec2 v_uv;
in vec3 v_wpos;
in float v_seed;
in float v_tile;
in float v_yaw;
out vec4 o_color;
uniform sampler2D u_atlas_albedo;
uniform sampler2D u_atlas_normal;
uniform mat4 u_view;
uniform float u_tiles;
uniform vec3 u_tint;
uniform float u_radius;
void main() {
if (v_wpos.y < u_clip_y) discard;
vec2 uv = vec2((v_tile + v_uv.x) / u_tiles, v_uv.y);
#ifdef SHADOW_PASS
// the map's texels are coarse: read a finer mip so the crown's coverage is not averaged away
vec4 a = texture(u_atlas_albedo, uv, -3.0);
if (a.a < 0.22) discard;
#else
vec4 a = texture(u_atlas_albedo, uv);
float rawA = a.a;
a.rgb /= max(a.a, 1e-3); // the atlas mips are premultiplied by coverage
// alpha-to-coverage, sharpened per mip so the silhouette stays crisp at any distance
float cov = (a.a - 0.3) / max(fwidth(a.a), 1e-4) + 0.5;
if (cov < 0.02) discard;
a.a = clamp(cov, 0.0, 1.0);
#endif
#ifdef SHADOW_PASS
return;
#else
// The normal atlas is cleared to black where nothing was drawn, and its mips average
// that black into every silhouette texel — decoded, a half-covered texel pointed away
// from everything and shaded near black, so each tree wore a dark outline. Dividing by
// the same coverage restores the normal (and the AO in .a) of the covered part.
vec4 nn = texture(u_atlas_normal, uv) / max(rawA, 1e-3);
vec3 n = clamp(nn.rgb, 0.0, 1.0) * 2.0 - 1.0;
// the tile was baked from angle tile*2pi/tiles around the canonical tree; rotate by the instance yaw
float s = sin(v_yaw), c = cos(v_yaw);
n = vec3(c * n.x + s * n.z, n.y, -s * n.x + c * n.z);
n = normalize(n);
// canopy hull normal: a rounded shell over the card, blended with the baked detail
float far = smoothstep(200.0, 1200.0, length(v_wpos - u_cam_pos));
vec2 q = vec2(v_uv.x * 2.0 - 1.0, v_uv.y * 2.0 - 1.0);
vec3 toCam = normalize(u_cam_pos - v_wpos); toCam.y = 0.0; toCam = normalize(toCam);
vec3 right = vec3(-toCam.z, 0.0, toCam.x);
vec3 hull = normalize(right * q.x * 0.8 + vec3(0.0, 1.0, 0.0) * (q.y * 0.6 + 0.35) + toCam * 0.7);
n = normalize(mix(hull, n, mix(0.65, 0.35, far)));
float dist = length(v_wpos - u_cam_pos);
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
vec3 alb = a.rgb * u_tint * (0.85 + 0.3 * fract(v_seed * 7.13)) * regionTint(v_wpos, 0.4);
// a distant stand reads as a dark mass, not as bright separate sprites
alb = mix(alb, alb * vec3(0.72, 0.78, 0.72), far);
// the card itself is the caster: look up the shadow a little toward the sun so it does not self-shadow
float shadow = sunShadow(v_wpos + u_sun_dir * u_radius * 0.7, vec3(0, 1, 0), viewDepth);
// crowns are dense: darken toward the centre of the card as a cheap interior occlusion
float interior = 1.0 - 0.45 * smoothstep(0.9, 0.3, abs(q.x)) * smoothstep(1.0, 0.2, v_uv.y);
// ground contact: the lowest part of anything sitting on the ground is occluded by it
// (a boulder's underside, a trunk's base); without it a far boulder is a sticker on the grass
interior *= mix(0.55, 1.0, smoothstep(0.0, 0.3, v_uv.y));
vec3 col = shade(v_wpos, n, alb, 0.85, 0.0, clamp(nn.a, 0.0, 1.0) * 0.8 * interior, shadow * interior, viewDepth);
col += alb * skyIrradiance(vec3(0, 1, 0)) * 0.06;
col = applyFog(col, v_wpos, dist);
o_color = vec4(sane(col), a.a);
#endif
}

View file

@ -0,0 +1,42 @@
// camera-facing (around y) card per instance, showing the atlas tile nearest the view angle
layout(location = 0) in vec2 a_xy; // [-0.5, 0.5] x [0, 1]
layout(location = 1) in vec2 a_uv;
layout(location = 3) in vec4 i_pos; // x y z scale
layout(location = 4) in vec4 i_rot; // sin cos seed wind
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_light_vp;
uniform vec3 u_cam_pos;
uniform vec3 u_face_dir; // direction the cards face (to the camera, or the sun in the shadow pass)
uniform float u_radius;
uniform float u_height;
uniform float u_tiles;
out vec2 v_uv;
out vec3 v_wpos;
out float v_seed;
out float v_tile;
out float v_yaw;
void main() {
vec3 center = i_pos.xyz;
#ifdef SHADOW_PASS
vec3 toCam = normalize(vec3(u_face_dir.x, 0.0, u_face_dir.z));
#else
vec3 toCam = u_cam_pos - center; toCam.y = 0.0; toCam = normalize(toCam);
#endif
vec3 right = vec3(-toCam.z, 0.0, toCam.x);
float yaw = atan(i_rot.x, i_rot.y);
// angle of the viewer around the (rotated) tree, in tile units
float ang = atan(toCam.x, -toCam.z) - yaw;
float t = floor(fract(ang / 6.2831853) * u_tiles + 0.5);
v_tile = mod(t, u_tiles);
v_yaw = yaw;
vec3 w = center + right * (a_xy.x * 2.0 * u_radius * i_pos.w) + vec3(0.0, a_xy.y * u_height * i_pos.w, 0.0);
v_wpos = w;
v_uv = a_uv;
v_seed = i_rot.z;
#ifdef SHADOW_PASS
gl_Position = u_light_vp * vec4(w, 1.0);
#else
gl_Position = u_proj * u_view * vec4(w, 1.0);
#endif
}

View file

@ -0,0 +1,272 @@
// ---- PBR + IBL + cascaded shadows + aerial perspective (shared) ---------------------
uniform sampler2D u_irradiance; // equirect, diffuse-convolved sky
uniform sampler2DArray u_prefilter; // equirect, GGX-prefiltered sky per roughness level
uniform sampler2D u_brdf; // split-sum BRDF LUT
#define CASCADES 5
uniform sampler2DArrayShadow u_shadow; // CASCADES layers
float shadowTap(vec2 uv, int c, float ref) { return texture(u_shadow, vec4(uv, float(c), ref)); }
uniform mat4 u_cascade_vp[CASCADES];
uniform float u_cascade_split[CASCADES]; // view-space far distance of each cascade
uniform float u_cascade_range[CASCADES]; // light-frustum depth extent of each cascade (m)
uniform float u_cascade_texel[CASCADES]; // shadow texel size of each cascade (m)
uniform vec3 u_sun_dir; // toward the sun
uniform vec3 u_sun_color; // radiance
uniform vec3 u_cam_pos;
uniform float u_prefilter_levels;
uniform float u_fog_density;
uniform float u_fog_height_falloff;
uniform float u_clip_y; // planar-reflection pass: discard everything below this height
uniform float u_spec_scale; // 1 for surfaces; foliage crowns get a fraction: needles are
// tiny rough cylinders, not sheets, and a crown of card quads
// seen at grazing angles otherwise mirrors the sky and frosts
const float PI = 3.14159265359;
// never let a NaN or an infinity reach the frame: it would smear through the bloom pyramid
vec3 sane(vec3 c) { return (any(isnan(c)) || any(isinf(c))) ? vec3(0.0) : c; }
vec2 equirectUV(vec3 d) {
return vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
}
// the HDRI itself is read through a yaw rotation (u_sky_rot = sin, cos), so the sun can be
// placed where the scene wants it; the convolved maps are built through the same rotation
uniform vec2 u_sky_rot;
vec2 skyUV(vec3 d) {
vec3 r = vec3(u_sky_rot.y * d.x + u_sky_rot.x * d.z, d.y, -u_sky_rot.x * d.x + u_sky_rot.y * d.z);
return equirectUV(r);
}
// The HDRI is a pure sky: below the horizon it is a flat bright grey, not ground. Anything
// whose normal points down — the underside of a needle card, the lower half of a crown —
// was lighting itself from that grey and came out white. Below the horizon the light is
// what the ground reflects: the horizon sky times a meadow albedo.
const vec3 GROUND_ALB = vec3(0.30, 0.34, 0.14);
// the time of day (daylight.ludic): the sky's light scaled toward night, and the campfire
uniform vec3 u_ibl_scale;
uniform float u_daylight;
uniform vec3 u_fire_pos;
uniform vec3 u_fire_color;
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
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));
vec3 ground = skyIrradianceRaw(normalize(vec3(n.x, 0.15, n.z) + vec3(1e-4, 0.0, 0.0))) * GROUND_ALB;
return mix(ground, up, smoothstep(-0.25, 0.2, n.y));
}
vec3 skyPrefilteredRaw(vec3 r, float rough) {
float lv = rough * (u_prefilter_levels - 1.0);
float l0 = floor(lv);
float l1 = min(l0 + 1.0, u_prefilter_levels - 1.0);
vec2 uv = equirectUV(r);
return mix(texture(u_prefilter, vec3(uv, l0)).rgb, texture(u_prefilter, vec3(uv, l1)).rgb, lv - l0) * u_ibl_scale;
}
// the campfire: one warm point light, out by twelve metres
vec3 fireLight(vec3 wpos, vec3 n, vec3 albedo) {
vec3 d = u_fire_pos - wpos;
float r2 = max(dot(d, d), 0.04);
vec3 l = d * inversesqrt(r2);
float att = smoothstep(14.0, 5.0, sqrt(r2)) / (0.6 + r2);
return albedo / PI * u_fire_color * max(dot(n, l), 0.0) * att;
}
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);
if (u_hand_cone > -1.0) {
float c = dot(-l, u_hand_dir);
att *= smoothstep(u_hand_cone, u_hand_cone + 0.12, c);
}
return albedo / PI * u_hand_color * max(dot(n, l), 0.0) * att;
}
vec3 skyPrefiltered(vec3 r, float rough) {
vec3 up = skyPrefilteredRaw(vec3(r.x, max(r.y, 0.0), r.z), rough);
vec3 ground = skyPrefilteredRaw(normalize(vec3(r.x, 0.15, r.z) + vec3(1e-4, 0.0, 0.0)), max(rough, 0.6)) * GROUND_ALB;
return mix(ground, up, smoothstep(-0.2, 0.15, r.y));
}
float D_GGX(float NoH, float a) { float a2 = a * a; float d = NoH * NoH * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); }
float V_Smith(float NoV, float NoL, float a) {
float a2 = a * a;
float gv = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
float gl = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
return 0.5 / max(gv + gl, 1e-4);
}
vec3 F_Schlick(float VoH, vec3 f0) { float f = pow(1.0 - VoH, 5.0); return f0 + (1.0 - f0) * f; }
vec3 F_SchlickRough(float NoV, vec3 f0, float rough) { return f0 + (max(vec3(1.0 - rough), f0) - f0) * pow(1.0 - NoV, 5.0); }
// interleaved-gradient noise for rotated PCF taps
float ign(vec2 p) { return fract(52.9829189 * fract(0.06711056 * p.x + 0.00583715 * p.y)); }
float cascadeRange(int c) { return u_cascade_range[c]; }
float cascadeTexel(int c) { return u_cascade_texel[c]; }
// biasWorld in metres; the receiver is pushed along its normal by a texel first
float shadowSample(int c, vec3 wpos, float biasWorld) {
vec4 lp = u_cascade_vp[c] * vec4(wpos, 1.0);
vec3 p = lp.xyz / lp.w * 0.5 + 0.5;
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) return 1.0;
float bias = biasWorld / cascadeRange(c);
float texel = 1.0 / 2048.0;
float r = ign(gl_FragCoord.xy) * 6.2831853;
float cs = cos(r), sn = sin(r);
mat2 rot = mat2(cs, sn, -sn, cs);
float s = 0.0;
const vec2 taps[8] = vec2[8](vec2(-0.7071, 0.7071), vec2(-0.0, -0.875), vec2(0.5303, 0.5303), vec2(-0.625, -0.0),
vec2(0.3536, -0.3536), vec2(-0.0, 0.375), vec2(-0.1768, -0.1768), vec2(0.125, 0.0));
// the far cascades' texels are metres wide: a wider filter turns their staircase into a penumbra
float rad = texel * ((c >= 4) ? 2.6 : (c == 3) ? 2.0 : 1.5);
for (int i = 0; i < 8; i++) {
vec2 off = rot * taps[i] * rad;
s += shadowTap(p.xy + off, c, p.z - bias);
}
return s / 8.0;
}
// one cascade's lookup, with a normal offset and a slope-scaled depth bias
float shadowSlope(int c, vec3 wpos, vec3 n, float tanT) {
float tx = cascadeTexel(c);
float filt = (c >= 4) ? 2.6 : ((c == 3) ? 2.0 : 1.5); // matches shadowSample's rad
vec3 wp = wpos + n * tx * (2.5 + 1.5 * tanT);
return shadowSample(c, wp, tx * (1.0 + filt * tanT) + 0.02);
}
// The baked height-field shadow (tershadow.frag): R = the lowest lit height over this
// ground texel, G = distance to the occluder that set it. Any receiver — ground, crown,
// card, water — compares its own height, so everything agrees on where the hill's
// shadow falls. The penumbra widens with the occluder's distance like a real one.
uniform sampler2D u_tershadow;
uniform vec2 u_ts_origin;
uniform float u_ts_half;
uniform float u_ts_on;
uniform sampler2D u_ts_height;
// the ground's normal under a world position (4 m texels): cover standing on the ground
// is lit with this beyond a few tens of metres, so a hillside and the grass on it agree
vec3 terrainNormalAt(vec3 wpos) {
vec2 uv = (wpos.xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
float step = 1.0 / float(textureSize(u_ts_height, 0).x);
float world = step * 2.0 * u_ts_half;
float hl = texture(u_ts_height, uv - vec2(step, 0)).r, hr = texture(u_ts_height, uv + vec2(step, 0)).r;
float hd = texture(u_ts_height, uv - vec2(0, step)).r, hu = texture(u_ts_height, uv + vec2(0, step)).r;
return normalize(vec3(hl - hr, 2.0 * world, hd - hu));
}
float terrainShadow(vec3 wpos) {
if (u_ts_on < 0.5) return 1.0;
vec2 uv = (wpos.xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
vec2 s = texture(u_tershadow, uv).rg;
float w = 0.6 + 0.02 * s.y;
return smoothstep(-w, w, wpos.y + 0.25 - s.x);
}
uniform int u_force_cascade;
// the far-field version: one hardware 2x2 tap in the cascade, no rotated disc, no blend
float sunShadowCheap(vec3 wpos, vec3 n, float viewDepth) {
int c = CASCADES - 1;
for (int i = 0; i < CASCADES - 1; i++) { if (viewDepth < u_cascade_split[i]) { c = i; break; } }
float tx = cascadeTexel(c);
vec4 lp = u_cascade_vp[c] * vec4(wpos + n * tx * 2.5, 1.0);
vec3 p = lp.xyz / lp.w * 0.5 + 0.5;
float s = 1.0;
if (p.x >= 0.0 && p.x <= 1.0 && p.y >= 0.0 && p.y <= 1.0 && p.z <= 1.0) s = shadowTap(p.xy, c, p.z - (tx * 2.0 + 0.02) / cascadeRange(c));
return min(s, terrainShadow(wpos));
}
float sunShadow(vec3 wpos, vec3 n, float viewDepth) {
int c = CASCADES - 1;
if (u_force_cascade >= 0) { float tx0 = cascadeTexel(u_force_cascade); return shadowSample(u_force_cascade, wpos + n * tx0 * 1.5, tx0 * 1.5 + 0.02); }
for (int i = 0; i < CASCADES - 1; i++) { if (viewDepth < u_cascade_split[i]) { c = i; break; } }
float NoL = max(dot(n, u_sun_dir), 0.0);
// Depth across one shadow texel changes by texel * tan(theta) on a surface lit at
// theta from its normal, and the PCF disc reaches `filt` texels out, so the bias must
// cover the drop over the whole filter rather than a single texel. The old form used
// (1 - NoL): at NoL = 0.2 that is 0.8 where tan(theta) is 4.9, six times short. With
// the caster and receiver now the same mesh, that shortfall is what let the terrain
// shadow itself along its own triangle edges — a faint grid over the whole slope.
float tanT = min(sqrt(max(1.0 - NoL * NoL, 0.0)) / max(NoL, 0.05), 10.0);
float s = shadowSlope(c, wpos, n, tanT);
// blend across the cascade edge
float edge = u_cascade_split[c];
float f = smoothstep(edge * 0.85, edge, viewDepth);
if (f > 0.0 && c < CASCADES - 1) {
s = mix(s, shadowSlope(c + 1, wpos, n, tanT), f);
}
return min(s, terrainShadow(wpos));
}
// patchy sunlight: a cloud layer projected along the sun onto the ground
uniform float u_cloud_shadow; // strength
uniform float u_time;
// the mask is baked into the height-field shadow texture's B (tershadow.frag); the
// projection along the sun and the drift are a uv shift
float cloudShadow(vec3 wpos) {
if (u_cloud_shadow <= 0.0 || u_ts_on < 0.5) return 1.0;
float h = 1400.0 - wpos.y;
vec2 c = wpos.xz + u_sun_dir.xz / max(u_sun_dir.y, 0.1) * h;
c += vec2(u_time * 3.0, u_time * 1.2);
vec2 uv = (c - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 1.0;
return 1.0 - u_cloud_shadow * texture(u_tershadow, uv).b;
}
// regional vegetation colour: aspen groves and drier ridges read lighter and yellower than
// the dark spruce and the lush hollows (a slow noise over the world, shaped by elevation)
vec3 regionTint(vec3 wpos, float strength) {
float n = fbm(wpos.xz * 0.0018 + 4.0, 3) * 0.5 + 0.5;
float aspen = smoothstep(0.52, 0.7, n) * smoothstep(520.0, 250.0, wpos.y);
float dry = smoothstep(0.35, 0.15, fbm(wpos.xz * 0.004 + 9.0, 3) * 0.5 + 0.5);
vec3 t = vec3(1.0);
t = mix(t, vec3(1.25, 1.3, 0.85), aspen * strength);
t = mix(t, vec3(1.15, 1.05, 0.7), dry * strength * 0.6);
return t;
}
// direct + image-based lighting for one surface
vec3 shade(vec3 wpos, vec3 n, vec3 albedo, float rough, float metal, float ao, float shadow, float viewDepth) {
vec3 v = normalize(u_cam_pos - wpos);
vec3 l = u_sun_dir;
vec3 h = normalize(v + l);
float NoV = max(dot(n, v), 1e-2);
float NoL = max(dot(n, l), 0.0);
float NoH = max(dot(n, h), 0.0);
float VoH = max(dot(v, h), 0.0);
rough = clamp(rough, 0.045, 1.0);
float a = rough * rough;
vec3 f0 = mix(vec3(0.04), albedo, metal);
vec3 F = F_Schlick(VoH, f0);
vec3 spec = min(D_GGX(NoH, a) * V_Smith(NoV, NoL, a), 12.0) * F * u_spec_scale; // cap the highlight: no half-float overflow, no fireflies
vec3 kd = (1.0 - F) * (1.0 - metal);
vec3 direct = (kd * albedo / PI + spec) * u_sun_color * NoL * shadow * cloudShadow(wpos);
// IBL
vec3 Fr = F_SchlickRough(NoV, f0, rough);
vec3 kdi = (1.0 - Fr) * (1.0 - metal);
vec3 irr = skyIrradiance(n);
vec3 diffuseIBL = kdi * albedo * irr;
vec3 r = reflect(-v, n);
vec3 pre = skyPrefiltered(r, rough);
vec2 brdf = texture(u_brdf, vec2(NoV, rough)).rg;
vec3 specIBL = pre * (Fr * brdf.x + brdf.y) * u_spec_scale;
// one bounce off the sunlit ground onto whatever faces it (a warm fill from below)
vec3 groundAlb = vec3(0.16, 0.2, 0.07);
vec3 bounce = kdi * albedo * groundAlb * (u_sun_color * max(u_sun_dir.y, 0.0) / PI + irr) * clamp(0.5 - 0.5 * n.y, 0.0, 1.0) * 0.5;
// specular occlusion from ao
float so = clamp(pow(NoV + ao, exp2(-16.0 * rough - 1.0)) - 1.0 + ao, 0.0, 1.0);
// check for NaN before min(): on this GPU min(NaN, x) returns x, which would hide the fault as a hot pixel
vec3 c = direct + (diffuseIBL * ao + specIBL * so) + bounce * ao + (fireLight(wpos, n, albedo) + handLight(wpos, n, albedo)) * ao;
if (any(isnan(direct)) || any(isnan(diffuseIBL)) || any(isnan(specIBL)) || isnan(so)) return vec3(0.0);
return sane(min(c, vec3(4096.0)));
}
// aerial perspective: exponential height fog toward the horizon sky, with sun inscatter
vec3 applyFog(vec3 col, vec3 wpos, float dist) {
vec3 dir = normalize(wpos - u_cam_pos);
float hf = u_fog_height_falloff;
float t = dir.y * hf;
float integ = (abs(t) > 1e-4) ? (1.0 - exp(-dist * t)) / t : dist * (1.0 - 0.5 * dist * t);
float fogAmt = u_fog_density * exp(-u_cam_pos.y * hf) * integ;
float f = 1.0 - exp(-fogAmt);
vec3 fogCol = skyPrefiltered(vec3(dir.x, max(dir.y, 0.02), dir.z), 0.6);
float sunAmt = pow(max(dot(dir, u_sun_dir), 0.0), 8.0);
fogCol += u_sun_color * 0.02 * sunAmt;
return mix(col, fogCol, clamp(f, 0.0, 1.0));
}

View file

@ -0,0 +1,185 @@
in vec3 v_wpos;
in vec3 v_nrm;
in vec2 v_uv;
in float v_seed;
in vec2 v_rot;
in float v_hull;
uniform float u_model_h;
out vec4 o_color;
uniform sampler2D u_diff;
uniform sampler2D u_nrm;
uniform sampler2D u_arm;
uniform mat4 u_view;
uniform vec3 u_tint;
uniform float u_rough_scale;
uniform float u_emissive; // self-lit (a flame): albedo added back after shading
#ifdef BLADE
uniform vec3 u_blade_base;
uniform vec3 u_blade_tip;
#endif
#ifdef CARD
uniform float u_cull; // the layer's cull distance (m); 0 = none
#endif
mat3 cotangentFrame(vec3 N, vec3 p, vec2 uv) {
vec3 dp1 = dFdx(p), dp2 = dFdy(p);
vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
vec3 dp2perp = cross(dp2, N), dp1perp = cross(N, dp1);
vec3 T = dp2perp * duv1.x + dp1perp * duv2.x;
vec3 B = dp2perp * duv1.y + dp1perp * duv2.y;
float invmax = inversesqrt(max(dot(T, T), dot(B, B)) + 1e-12);
return mat3(T * invmax, B * invmax, N);
}
void main() {
if (v_wpos.y < u_clip_y) discard;
vec3 N = normalize(v_nrm);
if (!gl_FrontFacing && v_hull >= 0.0) N = -N;
#ifdef CARD
// a baked card: albedo with coverage (premultiplied mips), normal in the card's own frame
vec4 ca = texture(u_diff, v_uv);
float rawA = ca.a;
ca.rgb /= max(ca.a, 1e-3);
#ifdef SHADOW_PASS
if (ca.a < 0.3) discard;
float cardAlpha = 1.0;
return;
#else
float cov = (ca.a - 0.4) / max(fwidth(ca.a), 1e-4) + 0.5;
if (cov < 0.02) discard;
float cardAlpha = clamp(cov, 0.0, 1.0);
#endif
#endif
float dist = length(v_wpos - u_cam_pos);
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
#if defined(CARD) && !defined(SHADOW_PASS)
// A clump card at 400 m is a two-pixel disc of pure leaf colour on whatever the ground
// is doing — on dark scree it glows. Dissolve toward the cull distance: the carpet
// drape on the ground carries the meadow's colour from there on.
if (u_cull > 1.0) cardAlpha *= 1.0 - smoothstep(u_cull * 0.55, u_cull, dist);
if (cardAlpha < 0.02) discard;
#endif
vec3 alb;
vec3 n;
vec3 arm;
float meshAlpha = 1.0;
#ifdef BLADE
// a procedural blade: dark at the root, lighter at the tip; some blades gone to seed
float t = clamp(v_uv.y, 0.0, 1.0);
alb = mix(u_blade_base, u_blade_tip, t * t) * regionTint(v_wpos, 0.35);
float dry = smoothstep(0.7, 0.8, fract(v_seed * 3.17));
alb = mix(alb, vec3(0.40, 0.36, 0.13) * (0.45 + 0.55 * t), dry * 0.75);
float patchy = fbm(v_wpos.xz * 0.045, 3) * 0.5 + 0.5;
alb *= mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy);
// a far tuft is a patch of the meadow, darker than a lit blade tip and never straw
if (v_hull < 0.0) alb = mix(u_blade_base, u_blade_tip, 0.45) * regionTint(v_wpos, 0.35) * mix(vec3(0.7, 0.8, 0.55), vec3(1.1, 1.05, 0.85), patchy) * 0.72;
// a rounded cross-section reads softer than a flat card
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = (v_hull < 0.0) ? N : normalize(N + side * (v_uv.x * 2.0 - 1.0) * 0.6);
arm = vec3(mix(0.2, 1.0, t * t), 0.85, 0.0);
#elif defined(CARD)
// thin grass is lit from either side: face the card toward the sun before shading
if (dot(N, u_sun_dir) < 0.0) N = -N;
// the normal atlas is premultiplied by coverage like the albedo (see impostor.frag)
vec4 cn = texture(u_nrm, v_uv) / max(rawA, 1e-3);
cn = clamp(cn, 0.0, 1.0);
vec3 bn = cn.rgb * 2.0 - 1.0;
// the card frame: right along the quad, up, and out of the quad
vec3 T = normalize(cross(vec3(0.0, 1.0, 0.0), N) + vec3(1e-5));
n = normalize(T * -bn.x + vec3(0.0, 1.0, 0.0) * bn.y + N * max(abs(bn.z), 0.25));
n = normalize(mix(n, normalize(N + vec3(0.0, 0.8, 0.0)), 0.35));
// A clump is a few pixels at 100 m: what the eye reads there is the hillside's shading,
// and a card facing the sun on a slope facing away from it glows against the ground
// like a sticker. Light it with the ground's own normal as it recedes (as the blades
// already do), so cover and terrain darken together.
#ifdef CHEAP
n = terrainNormalAt(v_wpos);
#else
n = normalize(mix(n, terrainNormalAt(v_wpos), smoothstep(25.0, 90.0, dist)));
#endif
alb = ca.rgb * 1.05 * regionTint(v_wpos, 0.8);
arm = vec3(mix(0.5, 1.0, clamp(v_uv.y, 0.0, 1.0)) * (0.6 + 0.4 * cn.a), 0.85, 0.0);
#elif defined(FLOWER)
// a lupine: green stem (uv.x < 1), violet florets above (uv.x in [1,2]), tinted per plant
float hue = fract(v_seed * 5.71);
float vy = clamp(v_uv.y, 0.0, 1.0);
vec3 violet = mix(vec3(0.07, 0.03, 0.32), vec3(0.28, 0.06, 0.36), hue);
vec3 tipc = mix(violet, vec3(0.5, 0.3, 0.7), 0.3);
if (v_uv.x >= 2.0) {
alb = vec3(0.05, 0.13, 0.025) * (0.7 + 0.6 * vy);
} else if (v_uv.x >= 1.0) {
float f = fract(v_uv.x);
alb = mix(violet, tipc, vy) * (0.65 + 0.35 * abs(f * 2.0 - 1.0));
// florets as little lobes: darker between them, a paler lip on each
float lobe = 0.55 + 0.45 * abs(sin(vy * 9.0 + f * 6.0));
alb = mix(alb * lobe, vec3(0.55, 0.45, 0.75), 0.18 * smoothstep(0.6, 1.0, lobe));
} else {
alb = vec3(0.07, 0.16, 0.03);
}
vec3 side = normalize(cross(N, vec3(0.0, 1.0, 0.0)) + vec3(1e-4));
n = normalize(N + side * (fract(v_uv.x) * 2.0 - 1.0) * 0.7);
arm = vec3(0.9, 0.7, 0.0);
#else
vec4 d = texture(u_diff, v_uv);
#ifdef ALPHA_TEST
// A needle sprig's alpha averages away in the mips, so a plain 0.5 test strips the
// crown bare past 50 m. Scale the alpha back up by the mip level (Castano's alpha
// mipmaps, done at sample time) and sharpen the edge with its screen derivative, then
// let alpha-to-coverage resolve it.
float lod = textureQueryLod(u_diff, v_uv).x;
float a = min(d.a * (1.0 + 0.45 * max(lod, 0.0)), 1.0);
float cov = (a - 0.4) / max(fwidth(a), 1e-4) + 0.5;
if (cov < 0.02) discard;
meshAlpha = clamp(cov, 0.0, 1.0);
#endif
vec3 tn = texture(u_nrm, v_uv).rgb * 2.0 - 1.0;
mat3 tbn = cotangentFrame(N, v_wpos, v_uv);
n = normalize(tbn * tn);
arm = texture(u_arm, v_uv).rgb;
n = normalize(mix(n, N, smoothstep(30.0, 120.0, dist)));
alb = d.rgb;
// a crown's interior is occluded by its own cards
if (u_model_h > 2.0) arm.r *= mix(0.5, 1.0, v_hull);
#endif
alb *= u_tint * (0.85 + 0.3 * fract(v_seed * 7.13));
#ifdef CARD
// a card is its own caster: look up the shadow a little above and in front of it, and let
// light bleed through the thin clump as real grass does
#ifdef CHEAP
float shadow = cloudShadow(v_wpos) * terrainShadow(v_wpos);
#else
float shadow = mix(1.0, sunShadow(v_wpos + N * 0.1 + vec3(0.0, 0.2, 0.0), N, viewDepth), 0.55);
#endif
#else
float shadow = sunShadow(v_wpos, N, viewDepth);
#endif
#ifdef FOLIAGE
// leaves and needles are matte at every angle: no grazing Fresnel on a two-sided card
float roughF = 1.0;
#else
float roughF = clamp(arm.g * u_rough_scale, 0.35, 1.0);
#endif
vec3 col = shade(v_wpos, n, alb, roughF, 0.0, arm.r, shadow, viewDepth);
#ifdef FOLIAGE
// thin-leaf translucency: light leaking through toward the viewer, and a wrapped diffuse
vec3 v = normalize(u_cam_pos - v_wpos);
float back = pow(max(dot(-v, u_sun_dir), 0.0), 3.0);
float wrap = max(dot(N, u_sun_dir) * 0.5 + 0.5, 0.0);
// Only a thin leaf a few metres away is translucent. A clump card at 200 m is a whole
// bush in two pixels, and giving it the leaf's glow toward the sun painted the
// backlit hillsides with lime discs. The term fades out with distance.
float thin = 1.0 - smoothstep(30.0, 140.0, dist);
// a dense crown of cards is not a thin leaf: much less light comes through it
if (u_model_h > 2.0) thin *= 0.3;
col += alb * u_sun_color * (0.14 * back + 0.05 * wrap) * thin * shadow * cloudShadow(v_wpos);
col += alb * skyIrradiance(vec3(0, 1, 0)) * 0.12 * arm.r;
#endif
col += alb * u_emissive;
if (any(isnan(col))) col = vec3(0.0);
col = applyFog(col, v_wpos, dist);
#ifdef CARD
o_color = vec4(sane(col), cardAlpha);
#else
o_color = vec4(sane(col), meshAlpha);
#endif
}

View file

@ -0,0 +1,102 @@
// instanced glTF model: attribute 3 = (x, y, z, scale), 4 = (sin yaw, cos yaw, seed, wind)
layout(location = 0) in vec3 a_pos;
layout(location = 1) in vec3 a_nrm;
layout(location = 2) in vec2 a_uv;
layout(location = 3) in vec4 i_pos;
layout(location = 4) in vec4 i_rot;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_light_vp;
uniform float u_time;
uniform float u_wind;
uniform float u_card_w;
uniform float u_card_h;
#ifdef BLADE
uniform vec3 u_cam_pos;
uniform float u_cull; // the blade ring's edge (m)
#endif
// Ground cover is placed on the CPU from a bilinear read of the 4 m height texels, but
// the terrain is drawn from a B-spline of the same texels — two different surfaces,
// up to half a metre apart on rough ground, which buried blades and floated cards.
// Cover layers (u_ground) read the surface the terrain actually draws, so they always
// stand on it, at any tessellation level, with no hand-tuned lift.
uniform float u_ground;
uniform sampler2D u_ts_height;
uniform vec2 u_ts_origin;
uniform float u_ts_half;
float heightSmooth(sampler2D tex, vec2 uv) {
vec2 res = vec2(textureSize(tex, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0;
vec2 w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
}
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
// Crown hull: a tree's needle cards are lit as if they were the surface of a rounded
// crown (normal from the crown's centre), not each as a flat top-lit quad, and the
// cards near the trunk are darkened as the crown's interior. This is how game trees
// have been shaded since SpeedTree; without it a card crown reads as frosted.
uniform float u_model_h; // the model's height (m), 0 = not a crown
out float v_hull; // 0 at the crown's axis .. 1 at its rim
void main() {
float s = i_rot.x, c = i_rot.y;
vec3 p = a_pos * i_pos.w;
#ifdef CARD
p = vec3(p.x * u_card_w, p.y * u_card_h, p.z * u_card_w);
#endif
vec3 n = a_nrm;
#ifdef BLADE
// distant blades: wider so a thinner field keeps its coverage, lit like the ground
// they stand on, and sunk into the carpet texture at the ring's edge instead of popping
float bd = distance(u_cam_pos.xz, i_pos.xz);
p.x *= 1.0 + 2.5 * smoothstep(12.0, 90.0, bd);
p.y *= 1.0 - smoothstep(u_cull * 0.72, u_cull, bd);
n = normalize(mix(n, vec3(0.0, 1.0, 0.0), smoothstep(15.0, 70.0, bd)));
#endif
v_rot = vec2(s, c);
p = vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z);
n = vec3(c * n.x + s * n.z, n.y, -s * n.x + c * n.z);
#ifdef WIND
// sway grows with height above the base; gust phase from the instance seed
float hgt = max(p.y, 0.0);
float ph = u_time * 1.7 + i_rot.z * 6.2831 + i_pos.x * 0.05 + i_pos.z * 0.07;
float sway = (sin(ph) * 0.6 + sin(ph * 2.3 + 1.0) * 0.4) * u_wind * i_rot.w;
p.x += sway * hgt * hgt * 0.35;
p.z += sway * hgt * hgt * 0.15 * cos(ph * 0.7);
#endif
v_hull = 1.0;
if (u_model_h > 2.0) {
vec3 cc = vec3(0.0, u_model_h * i_pos.w * 0.55, 0.0);
vec3 rel = p - cc;
float rr = length(rel.xz) / max(u_model_h * i_pos.w * 0.28, 0.1);
v_hull = clamp(rr, 0.0, 1.0);
vec3 hull = normalize(vec3(rel.x, rel.y * 0.5, rel.z) + vec3(0.0, 0.15, 0.0));
n = normalize(mix(n, hull, 0.7));
}
vec3 w = p + i_pos.xyz;
if (u_ground > 0.5) {
vec2 huv = (i_pos.xz - u_ts_origin) / (2.0 * u_ts_half) + 0.5;
w.y = heightSmooth(u_ts_height, huv) - 0.03 + p.y;
}
v_wpos = w;
v_nrm = n;
v_uv = a_uv;
v_seed = i_rot.z;
#ifdef SHADOW_PASS
gl_Position = u_light_vp * vec4(w, 1.0);
#else
gl_Position = u_proj * u_view * vec4(w, 1.0);
#endif
}

View file

@ -0,0 +1,27 @@
// ---- shared noise (value / gradient / fbm / ridged) ----------------------------
float hash1(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
vec2 hash2(vec2 p) { p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); return fract(sin(p) * 43758.5453123) * 2.0 - 1.0; }
float gnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(dot(hash2(i + vec2(0, 0)), f - vec2(0, 0)), dot(hash2(i + vec2(1, 0)), f - vec2(1, 0)), u.x),
mix(dot(hash2(i + vec2(0, 1)), f - vec2(0, 1)), dot(hash2(i + vec2(1, 1)), f - vec2(1, 1)), u.x), u.y);
}
float fbm(vec2 p, int oct) {
float a = 0.5, s = 0.0, n = 0.0;
mat2 r = mat2(0.8, 0.6, -0.6, 0.8) * 2.02;
for (int i = 0; i < oct; i++) { s += a * gnoise(p); n += a; a *= 0.5; p = r * p; }
return s / n;
}
float ridged(vec2 p, int oct) {
float a = 0.5, s = 0.0, w = 1.0;
mat2 r = mat2(0.8, 0.6, -0.6, 0.8) * 2.1;
for (int i = 0; i < oct; i++) { float n = 1.0 - abs(gnoise(p)); n = n * n * w; w = clamp(n * 1.5, 0.0, 1.0); s += a * n; a *= 0.5; p = r * p; }
return s;
}
// the dirt track: distance from a winding curve through the meadow
float pathDist(vec2 xz) {
float cx = 40.0 * sin(xz.y * 0.011) + 18.0 * sin(xz.y * 0.031 + 1.7) - 30.0;
float cx2 = -180.0 + 25.0 * sin(xz.y * 0.017 + 0.4) + (xz.y * 0.35);
return min(abs(xz.x - cx), abs(xz.x - cx2) + 1.0);
}

View file

@ -0,0 +1,14 @@
// 2D overlay: a texture times a colour; the font atlas is white glyphs on alpha
in vec2 v_uv;
in vec4 v_col;
uniform sampler2D u_tex;
uniform float u_is_font;
out vec4 o_color;
void main() {
vec4 t = texture(u_tex, v_uv);
if (u_is_font > 0.5) {
o_color = vec4(v_col.rgb, v_col.a * t.a);
} else {
o_color = vec4(v_col.rgb * t.rgb, v_col.a * t.a);
}
}

View file

@ -0,0 +1,13 @@
// 2D overlay (overlay.ludic): pixels with the origin top-left, straight to clip space
layout(location = 0) in vec2 a_pos;
layout(location = 1) in vec2 a_uv;
layout(location = 2) in vec4 a_col;
uniform vec2 u_screen;
out vec2 v_uv;
out vec4 v_col;
void main() {
vec2 p = a_pos / u_screen * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
v_uv = a_uv;
v_col = a_col;
}

View file

@ -0,0 +1,11 @@
#ifdef ALPHA_TEST
// foliage meshes are cut-out cards: their shadow must have the card's shape, not the quad's
in vec2 v_uv;
uniform sampler2D u_diff;
void main() {
float lod = textureQueryLod(u_diff, v_uv).x;
if (texture(u_diff, v_uv).a * (1.0 + 0.45 * max(lod, 0.0)) < 0.45) discard;
}
#else
void main() { }
#endif

View file

@ -0,0 +1,21 @@
// luma unsharp mask + film grain, on the final LDR image
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_src;
uniform vec2 u_texel;
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)); }
void main() {
vec3 c = texture(u_src, v_uv).rgb;
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 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));
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);
}

View file

@ -0,0 +1,43 @@
// a skinned glTF model (skin.ludic / actor.ludic): four joint influences per vertex
// blended on the GPU, then one model matrix. Writes the same varyings as model.vert so
// model.frag (lit) and shadow.frag (casters) shade it unchanged.
layout(location = 0) in vec3 a_pos;
layout(location = 1) in vec3 a_nrm;
layout(location = 2) in vec2 a_uv;
layout(location = 5) in vec4 a_joints; // integer indices, read as floats
layout(location = 6) in vec4 a_weights;
uniform mat4 u_model;
uniform mat4 u_bones[48];
uniform float u_skinned; // 0: a rigid model on the same path
uniform mat4 u_view;
uniform mat4 u_proj;
uniform mat4 u_light_vp;
out vec3 v_wpos;
out vec3 v_nrm;
out vec2 v_uv;
out float v_seed;
out vec2 v_rot;
out float v_hull;
void main() {
mat4 m = u_model;
if (u_skinned > 0.5) {
mat4 sk = a_weights.x * u_bones[int(a_joints.x + 0.5)]
+ a_weights.y * u_bones[int(a_joints.y + 0.5)]
+ a_weights.z * u_bones[int(a_joints.z + 0.5)]
+ a_weights.w * u_bones[int(a_joints.w + 0.5)];
m = u_model * sk;
}
vec4 w = m * vec4(a_pos, 1.0);
v_wpos = w.xyz;
v_nrm = normalize(mat3(m) * a_nrm);
v_uv = a_uv;
// model.frag scales the albedo by 0.85 + 0.3 * fract(seed * 7.13); this seed makes that 1
v_seed = 0.0701;
v_rot = vec2(0.0, 1.0);
v_hull = 1.0;
#ifdef SHADOW_PASS
gl_Position = u_light_vp * w;
#else
gl_Position = u_proj * u_view * w;
#endif
}

View file

@ -0,0 +1,40 @@
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_sky;
uniform mat4 u_inv_vp;
uniform float u_sky_gain;
uniform float u_sky_sat;
// a star: one hash per cell of the direction, a few of them bright, a slow twinkle
float starField(vec3 dir, float t) {
vec3 p = dir * 230.0;
vec3 c = floor(p);
vec3 f = p - c - 0.5;
float h = fract(sin(dot(c, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
float h2 = fract(h * 91.7);
float bright = smoothstep(0.972, 1.0, h);
float disc = smoothstep(0.42, 0.0, length(f));
float twinkle = 0.7 + 0.3 * sin(t * (1.5 + 3.0 * h2) + h2 * 40.0);
return bright * disc * twinkle * (0.5 + h2);
}
void main() {
vec4 a = u_inv_vp * vec4(v_uv * 2.0 - 1.0, 1.0, 1.0);
vec3 dir = normalize(a.xyz / a.w - u_cam_pos);
// level 0: the equirect seam (atan wraps) would otherwise pick the smallest mip along one column
vec3 col = min(textureLod(u_sky, skyUV(dir), 0.0).rgb, vec3(4096.0)) * u_sky_gain;
float l = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = max(mix(vec3(l), col, u_sky_sat), vec3(0.0));
// the photograph's sky dims with the day (daylight.ludic); the night adds its own
col *= u_ibl_scale;
float night = 1.0 - smoothstep(0.0, 0.45, u_daylight);
if (night > 0.0) {
vec3 nightCol = mix(vec3(0.012, 0.016, 0.034), vec3(0.003, 0.004, 0.010), clamp(dir.y, 0.0, 1.0));
float stars = starField(dir, u_time) * smoothstep(-0.02, 0.15, dir.y);
nightCol += stars * vec3(0.55, 0.6, 0.7) * night;
col += nightCol * night;
}
// below the horizon the HDRI ground is replaced by the fog colour
float below = smoothstep(0.0, -0.08, dir.y);
vec3 fogCol = skyPrefiltered(vec3(dir.x, 0.02, dir.z), 0.6);
col = mix(col, fogCol, below);
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,47 @@
// screen-space ambient occlusion from the resolved depth (half resolution)
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_depth;
uniform mat4 u_inv_proj;
uniform mat4 u_proj;
uniform vec2 u_texel;
uniform float u_radius; // world metres
uniform float u_intensity;
vec3 viewPos(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.xyz / p.w;
}
void main() {
vec3 P = viewPos(v_uv);
if (-P.z > 900.0) { o_color = vec4(1.0); return; }
// normal from the depth's neighbourhood (take the smaller difference on each axis)
vec3 Pr = viewPos(v_uv + vec2(u_texel.x, 0.0)), Pl = viewPos(v_uv - vec2(u_texel.x, 0.0));
vec3 Pu = viewPos(v_uv + vec2(0.0, u_texel.y)), Pd = viewPos(v_uv - vec2(0.0, u_texel.y));
vec3 dx = (abs(Pr.z - P.z) < abs(P.z - Pl.z)) ? Pr - P : P - Pl;
vec3 dy = (abs(Pu.z - P.z) < abs(P.z - Pd.z)) ? Pu - P : P - Pd;
vec3 N = normalize(cross(dx, dy));
float noise = ign(gl_FragCoord.xy);
float ao = 0.0;
const int S = 12;
float radius = u_radius * (1.0 + 0.01 * -P.z);
for (int i = 0; i < S; i++) {
float a = (float(i) + noise) * 2.3999632; // golden angle spiral
float r = sqrt((float(i) + 0.5 + noise) / float(S));
vec3 dir = vec3(cos(a) * r, sin(a) * r, sqrt(max(0.0, 1.0 - r * r)));
// hemisphere around N
vec3 up = abs(N.z) < 0.999 ? vec3(0, 0, 1) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, N)), b = cross(N, t);
vec3 s = P + (t * dir.x + b * dir.y + N * dir.z) * radius * (0.2 + 0.8 * r);
vec4 c = u_proj * vec4(s, 1.0);
vec2 suv = c.xy / c.w * 0.5 + 0.5;
if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) continue;
float sz = viewPos(suv).z;
float rangeCheck = smoothstep(0.0, 1.0, radius / max(abs(P.z - sz), 1e-3));
ao += (sz >= s.z + 0.02 * radius ? 1.0 : 0.0) * rangeCheck;
}
ao = 1.0 - u_intensity * ao / float(S);
// contact occlusion is a near-field effect: fade it out with distance
ao = mix(clamp(ao, 0.0, 1.0), 1.0, smoothstep(120.0, 350.0, -P.z));
o_color = vec4(ao, -P.z, 0.0, 1.0);
}

View file

@ -0,0 +1,19 @@
// depth-aware 4x4 blur of the ao (a) and indirect bounce (rgb)
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_ao;
uniform sampler2D u_depth;
uniform vec2 u_texel;
void main() {
float cd = texture(u_depth, v_uv).r;
vec4 sum = vec4(0.0);
float wsum = 0.0;
for (int y = -2; y < 2; y++) for (int x = -2; x < 2; x++) {
vec2 o = vec2(float(x) + 0.5, float(y) + 0.5) * u_texel;
vec4 s = texture(u_ao, v_uv + o);
float sd = texture(u_depth, v_uv + o).r;
float w = exp(-abs(sd - cd) * 4000.0);
sum += s * w; wsum += w;
}
o_color = sum / max(wsum, 1e-4);
}

View file

@ -0,0 +1,66 @@
// screen-space ambient occlusion + one indirect diffuse bounce (SSGI) from the previous
// frame's lit colour; half resolution, denoised over time by the TAA history it feeds
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_depth;
uniform sampler2D u_prev_color; // last frame's anti-aliased HDR colour
uniform mat4 u_inv_proj;
uniform mat4 u_proj;
uniform vec2 u_texel;
uniform float u_radius;
uniform float u_intensity;
uniform float u_frame;
vec3 viewPos(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.xyz / p.w;
}
void main() {
vec3 P = viewPos(v_uv);
if (-P.z > 900.0) { o_color = vec4(0.0, 0.0, 0.0, 1.0); return; }
vec3 Pr = viewPos(v_uv + vec2(u_texel.x, 0.0)), Pl = viewPos(v_uv - vec2(u_texel.x, 0.0));
vec3 Pu = viewPos(v_uv + vec2(0.0, u_texel.y)), Pd = viewPos(v_uv - vec2(0.0, u_texel.y));
vec3 dx = (abs(Pr.z - P.z) < abs(P.z - Pl.z)) ? Pr - P : P - Pl;
vec3 dy = (abs(Pu.z - P.z) < abs(P.z - Pd.z)) ? Pu - P : P - Pd;
vec3 N = normalize(cross(dx, dy));
// A fixed per-pixel dither, not a per-frame one. Advancing the sequence every frame
// spreads the sampling error over time, which is only an improvement if something
// then averages the frames; with no temporal anti-aliasing left it is just noise that
// changes every frame, and it was the largest single source of the flicker on movement.
float noise = ign(gl_FragCoord.xy);
float ao = 0.0;
vec3 gi = vec3(0.0);
float giW = 0.0;
const int S = 8;
float radius = u_radius * (1.0 + 0.01 * -P.z);
vec3 up = abs(N.z) < 0.999 ? vec3(0, 0, 1) : vec3(1, 0, 0);
vec3 t = normalize(cross(up, N)), b = cross(N, t);
for (int i = 0; i < S; i++) {
float a = (float(i) + noise) * 2.3999632;
float r = sqrt((float(i) + 0.5 + noise) / float(S));
vec3 dir = vec3(cos(a) * r, sin(a) * r, sqrt(max(0.0, 1.0 - r * r)));
vec3 wdir = t * dir.x + b * dir.y + N * dir.z;
vec3 s = P + wdir * radius * (0.2 + 0.8 * r);
vec4 c = u_proj * vec4(s, 1.0);
vec2 suv = c.xy / c.w * 0.5 + 0.5;
if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) continue;
vec3 sp = viewPos(suv);
float rangeCheck = smoothstep(0.0, 1.0, radius / max(abs(P.z - sp.z), 1e-3));
bool occluded = sp.z >= s.z + 0.02 * radius;
ao += (occluded ? 1.0 : 0.0) * rangeCheck;
// the occluder's lit colour bounces back toward P (weighted by how squarely it faces P)
if (occluded) {
vec3 toS = sp - P;
float d2 = max(dot(toS, toS), 1e-3);
float cosP = max(dot(N, toS) * inversesqrt(d2), 0.0);
vec3 col = sane(texture(u_prev_color, suv).rgb);
gi += col * cosP * rangeCheck;
giW += 1.0;
}
}
ao = 1.0 - u_intensity * ao / float(S);
float fade = smoothstep(120.0, 350.0, -P.z);
ao = mix(clamp(ao, 0.0, 1.0), 1.0, fade);
gi = (giW > 0.0 ? gi / float(S) : vec3(0.0)) * (1.0 - fade);
o_color = vec4(gi, ao);
}

View file

@ -0,0 +1,32 @@
// Second generation pass: copy the height into R and bake the B-spline surface normal into
// GBA, once, at texel resolution. terrain.frag used to differentiate the bicubic height
// per pixel — four bicubic reads, sixteen taps — for a quantity that never changes.
in vec2 v_uv;
out vec4 o;
uniform sampler2D u_src;
uniform float u_half;
float heightSmooth(sampler2D tex, vec2 uv) {
vec2 res = vec2(textureSize(tex, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0;
vec2 w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
}
void main() {
float step = 1.0 / float(textureSize(u_src, 0).x);
float world = step * 2.0 * u_half;
float hl = heightSmooth(u_src, v_uv - vec2(step, 0));
float hr = heightSmooth(u_src, v_uv + vec2(step, 0));
float hd = heightSmooth(u_src, v_uv - vec2(0, step));
float hu = heightSmooth(u_src, v_uv + vec2(0, step));
vec3 n = normalize(vec3(hl - hr, 2.0 * world, hd - hu));
o = vec4(texture(u_src, v_uv).r, n);
}

View file

@ -0,0 +1,467 @@
in vec3 v_wpos;
in vec2 v_huv;
out vec4 o_color;
#ifdef TFAST_2
#define fbm(p, o) 0.1
#define ridged(p, o) 0.3
#define gnoise(p) 0.1
#endif
uniform sampler2D u_height;
uniform float u_half;
uniform float u_texel; // height-map texel size in uv
uniform sampler2D u_grass_d; uniform sampler2D u_grass_n; uniform sampler2D u_grass_a;
uniform sampler2D u_ortho;
uniform sampler2D u_sunshadow; // the sun visibility this pixel already has (tersun.frag)
uniform float u_ortho_on;
uniform sampler2D u_rock_d; uniform sampler2D u_rock_n; uniform sampler2D u_rock_a;
uniform sampler2D u_snow_d; uniform sampler2D u_carpet; // the clump cards baked straight down (alpha = coverage)
uniform float u_carpet_on;
uniform float u_snow_line;
uniform float u_lake_level; // the ground just above the water is wet and dark
uniform vec2 u_origin; // world offset of the terrain grid
// The ground's own sun shadow comes from the baked height-field map (tershadow.frag),
// applied inside sunShadow() for every receiver in the scene.
// bicubic (B-spline) sample through four bilinear taps: the 10 m photo pixels stop reading as squares
vec3 orthoSmooth(vec2 uv) {
vec2 res = vec2(textureSize(u_ortho, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0, w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0, w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res, o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (texture(u_ortho, vec2(o0.x, o0.y)).rgb * s0.x + texture(u_ortho, vec2(o1.x, o0.y)).rgb * s1.x) * s0.y
+ (texture(u_ortho, vec2(o0.x, o1.y)).rgb * s0.x + texture(u_ortho, vec2(o1.x, o1.y)).rgb * s1.x) * s1.y;
}
uniform mat4 u_view;
// B-spline bicubic sample of the height field, through four bilinear taps. The height
// texture is only C0 under bilinear filtering: its slope jumps at every texel edge, and
// the mesh chords across each triangle, so the geometry and the normal were reading two
// different surfaces and the shading kinked along every triangle diagonal. Both stages
// call this, so they now agree on one smooth surface.
float heightSmooth(sampler2D tex, vec2 uv) {
vec2 res = vec2(textureSize(tex, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0;
vec2 w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
}
// baked at generation (ternormal.frag) into the height texture's GBA
vec3 terrainNormal(vec2 uv) {
return normalize(texture(u_height, uv).gba);
}
// stochastic (triangle-grid) sampling: three randomly offset / rotated taps blended by
// barycentric weights, so a scanned tile never repeats visibly
void triGrid(vec2 uv, out float w1, out float w2, out float w3, out vec2 v1, out vec2 v2, out vec2 v3) {
const mat2 skew = mat2(1.0, 0.0, -0.57735027, 1.15470054);
vec2 sk = skew * (uv * 3.4641016);
vec2 base = floor(sk);
vec3 t = vec3(fract(sk), 0.0);
t.z = 1.0 - t.x - t.y;
if (t.z > 0.0) { w1 = t.z; w2 = t.y; w3 = t.x; v1 = base; v2 = base + vec2(0, 1); v3 = base + vec2(1, 0); }
else { w1 = -t.z; w2 = 1.0 - t.y; w3 = 1.0 - t.x; v1 = base + vec2(1, 1); v2 = base + vec2(1, 0); v3 = base + vec2(0, 1); }
}
// The per-cell rotation must be applied to the DERIVATIVES as well as the coordinate.
// Handing textureGrad the gradients of the unrotated uv makes every cell sample with a
// footprint pointing the wrong way, so each one lands on a slightly different mip and
// anisotropy — and that per-cell difference is exactly the faint lattice over every
// surface. Returning the rotation lets the caller transform its gradients to match.
mat2 cellRot(vec2 cell) {
float a = hash1(cell) * 6.2831853;
float c = cos(a), s = sin(a);
return mat2(c, s, -s, c);
}
vec2 rotUV(vec2 uv, vec2 cell) {
return cellRot(cell) * uv + hash2(cell + 3.7) * 4.0;
}
// A tap whose weight rounds away is a tap not worth taking. The barycentric weights are
// raised to the fourth power to sharpen the blend, which leaves one of the three
// dominant over most of the plane and the other two often at a few thousandths; taking
// only the ones that carry any of the result, and renormalising over those, is
// indistinguishable from taking all three and is most of what this shader used to spend
// on the ground. TRI_EPS is the weight below which a tap cannot move an 8-bit channel.
#define TRI_EPS 0.004
vec4 sampleCarpet(vec2 uv, vec2 dx, vec2 dy) {
float w1, w2, w3; vec2 v1, v2, v3;
triGrid(uv * 0.3, w1, w2, w3, v1, v2, v3);
vec3 w = pow(vec3(w1, w2, w3), vec3(4.0)); w /= (w.x + w.y + w.z);
vec4 acc = vec4(0.0);
float wsum = 0.0;
if (w.x > TRI_EPS) { mat2 C = cellRot(v1); acc += textureGrad(u_carpet, rotUV(uv, v1), C * dx, C * dy) * w.x; wsum += w.x; }
if (w.y > TRI_EPS) { mat2 C = cellRot(v2); acc += textureGrad(u_carpet, rotUV(uv, v2), C * dx, C * dy) * w.y; wsum += w.y; }
if (w.z > TRI_EPS) { mat2 C = cellRot(v3); acc += textureGrad(u_carpet, rotUV(uv, v3), C * dx, C * dy) * w.z; wsum += w.z; }
return acc / max(wsum, 1e-4);
}
// Stochastic (triangle-grid) sampling: three randomly offset / rotated taps blended by
// barycentric weights, so a scanned tile never repeats visibly. The gradients are
// rotated per cell to match each tap's own rotation — handing textureGrad the gradients
// of the unrotated uv makes every cell sample with a footprint pointing the wrong way,
// landing on a different mip and anisotropy.
// one cell of the triangle grid: its rotation, its offset, and its three maps
void matTap(sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, vec2 cell, float wt,
inout vec3 alb, inout vec3 nsum, inout vec3 arm, inout float wsum) {
mat2 R = cellRot(cell);
vec2 u = R * uv + hash2(cell + 3.7) * 4.0;
vec2 gx = R * dx, gy = R * dy;
alb += textureGrad(d, u, gx, gy).rgb * wt;
nsum += (textureGrad(nm, u, gx, gy).rgb * 2.0 - 1.0) * wt;
arm += textureGrad(am, u, gx, gy).rgb * wt;
wsum += wt;
}
// Stochastic (triangle-grid) sampling: three randomly offset / rotated taps blended by
// barycentric weights, so a scanned tile never repeats visibly. The gradients are
// rotated per cell to match each tap's own rotation — handing textureGrad the gradients
// of the unrotated uv makes every cell sample with a footprint pointing the wrong way,
// landing on a different mip and anisotropy.
//
// The weights are sharpened to the fourth power, which leaves one cell dominant over
// most of the plane and the other two at a few thousandths. Everything a cell needs —
// its rotation (a hash, a sine and a cosine), its offset, its two rotated gradients —
// is computed inside its own test, so a cell that cannot move the result costs nothing.
void sampleMat(sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, out vec3 alb, out vec3 nrm, out vec3 arm) {
float w1, w2, w3; vec2 v1, v2, v3;
triGrid(uv * 0.3, w1, w2, w3, v1, v2, v3);
vec3 w = pow(vec3(w1, w2, w3), vec3(4.0)); w /= (w.x + w.y + w.z);
alb = vec3(0.0); arm = vec3(0.0);
vec3 nsum = vec3(0.0);
float wsum = 0.0;
if (w.x > TRI_EPS) { matTap(d, nm, am, uv, dx, dy, v1, w.x, alb, nsum, arm, wsum); }
if (w.y > TRI_EPS) { matTap(d, nm, am, uv, dx, dy, v2, w.y, alb, nsum, arm, wsum); }
if (w.z > TRI_EPS) { matTap(d, nm, am, uv, dx, dy, v3, w.z, alb, nsum, arm, wsum); }
float iw = 1.0 / max(wsum, 1e-4);
alb *= iw; arm *= iw;
// rotate the tangent normals back with their taps
nrm = normalize(nsum);
}
void samplePlain(sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, out vec3 alb, out vec3 nrm, out vec3 arm) {
alb = textureGrad(d, uv, dx, dy).rgb;
nrm = textureGrad(nm, uv, dx, dy).rgb * 2.0 - 1.0;
arm = textureGrad(am, uv, dx, dy).rgb;
}
// triplanar sample for steep rock
void sampleTri(sampler2D d, sampler2D nm, sampler2D am, vec3 p, vec3 dpx, vec3 dpy, vec3 n, float scale, out vec3 alb, out vec3 nrm, out vec3 arm) {
vec3 w = pow(abs(n), vec3(4.0)); w /= (w.x + w.y + w.z);
// The fourth power leaves ground facing one axis almost entirely on that axis's plane:
// a slope has to be within a few degrees of a diagonal before a second projection
// carries anything, and the third almost never does.
vec3 a0, n0, r0;
alb = vec3(0.0); arm = vec3(0.0);
vec3 nsum = vec3(0.0);
float wsum = 0.0;
if (w.x > TRI_EPS) {
samplePlain(d, nm, am, p.zy * scale, dpx.zy * scale, dpy.zy * scale, a0, n0, r0);
alb += a0 * w.x; arm += r0 * w.x;
nsum += vec3(n0.xy + n.zy, abs(n0.z) * n.x).zyx * w.x;
wsum += w.x;
}
if (w.y > TRI_EPS) {
samplePlain(d, nm, am, p.xz * scale, dpx.xz * scale, dpy.xz * scale, a0, n0, r0);
alb += a0 * w.y; arm += r0 * w.y;
nsum += vec3(n0.xy + n.xz, abs(n0.z) * n.y).xzy * w.y;
wsum += w.y;
}
if (w.z > TRI_EPS) {
samplePlain(d, nm, am, p.xy * scale, dpx.xy * scale, dpy.xy * scale, a0, n0, r0);
alb += a0 * w.z; arm += r0 * w.z;
nsum += vec3(n0.xy + n.xy, abs(n0.z) * n.z) * w.z;
wsum += w.z;
}
float iw = 1.0 / max(wsum, 1e-4);
alb *= iw; arm *= iw;
nrm = normalize(nsum);
}
vec3 dbg_n; vec3 dbg_alb; float dbg_shadow; vec3 dbg_mat;
// cheap = the far tier: single taps, noise at its mean, one shadow tap. Same code, same
// mean colour, so the tier boundary cannot show as a ring.
float fbmC(bool cheap, vec2 q, int o) { return cheap ? 0.0 : fbm(q, o); }
float ridgedC(bool cheap, vec2 q, int o) { return cheap ? 0.35 : ridged(q, o); }
float gnoiseC(bool cheap, vec2 q) { return cheap ? 0.0 : gnoise(q); }
vec3 orthoC(bool cheap, vec2 uv) { return cheap ? textureLod(u_ortho, uv, 1.0).rgb : orthoSmooth(uv); }
vec4 carpetC(bool cheap, vec2 uv, vec2 dx, vec2 dy) { return cheap ? textureGrad(u_carpet, uv, dx, dy) : sampleCarpet(uv, dx, dy); }
void matC(bool cheap, sampler2D d, sampler2D nm, sampler2D am, vec2 uv, vec2 dx, vec2 dy, out vec3 alb, out vec3 nrm, out vec3 arm) {
if (cheap) samplePlain(d, nm, am, uv, dx, dy, alb, nrm, arm); else sampleMat(d, nm, am, uv, dx, dy, alb, nrm, arm);
}
void triC(bool cheap, sampler2D d, sampler2D nm, sampler2D am, vec3 p, vec3 dpx, vec3 dpy, vec3 n, float scale, out vec3 alb, out vec3 nrm, out vec3 arm) {
if (cheap) { samplePlain(d, nm, am, p.xz * scale, dpx.xz * scale, dpy.xz * scale, alb, nrm, arm); nrm = normalize(vec3(nrm.x, 1.0, nrm.y) + vec3(0.0, 1e-3, 0.0)); }
else sampleTri(d, nm, am, p, dpx, dpy, n, scale, alb, nrm, arm);
}
vec3 groundShade(vec3 p, vec3 N, float slope, float dist, float viewDepth, bool cheap) {
// ---- material weights ----
float macro = fbmC(cheap, p.xz * 0.02, 2);
// a foot track: two metres wide, worn into whatever the ground is, not a painted band
float pathW = 0.6 * smoothstep(3.0 + 0.8 * macro, 1.0, pathDist(p.xz)) * smoothstep(0.35, 0.1, slope);
float rockW = max(smoothstep(0.30, 0.55, slope + 0.1 * macro), 0.9 * smoothstep(170.0, 300.0, p.y + 30.0 * macro));
// The ridge field places the snow line's raggedness and nothing else. Both terms below
// are zero more than 160 m under the snow line whatever it returns (macro and ridgeN
// can lift the test height by at most 100 m), which is the whole valley floor.
float snowW = 0.0;
if (p.y > u_snow_line - 160.0) {
float ridgeN = ridgedC(cheap, p.xz * 0.0018 + 11.0, 2);
snowW = smoothstep(u_snow_line - 60.0, u_snow_line + 60.0, p.y + 60.0 * macro + 40.0 * ridgeN) * smoothstep(0.55, 0.15, slope);
// wind-packed snow lingers in the gullies of the steep faces too
snowW = max(snowW, 0.6 * smoothstep(u_snow_line - 120.0, u_snow_line, p.y) * smoothstep(0.45, 0.2, slope) * smoothstep(0.55, 0.75, ridgeN));
}
float grassW = 1.0 - max(pathW, max(rockW, snowW));
// ---- what the photograph says is here -------------------------------------------
// This classification used to sit between the material samples, which meant every
// pixel sampled every material before anything knew which of them it would use. It
// runs first now: it costs three filtered taps of the survey image and it decides
// whether the scanned grass, rock and snow are needed at all.
vec3 oc = vec3(0.0);
float forestW = 0.0; // dense conifer: the ground under it is duff, not meadow
float screeC = 0.0; // bare ground: talus, moraine gravel, the lake's cobble shore
float snowC = 0.0;
bool orthoOn = u_ortho_on > 0.5;
#ifdef TFAST_4
orthoOn = false;
#endif
if (orthoOn) {
oc = orthoC(cheap, v_huv);
// (classified from a ~40 m blur: thresholding the raw 10 m pixels drew hard squares)
vec3 ocf = textureLod(u_ortho, v_huv, 2.0).rgb;
float gx = ocf.g - max(ocf.r, ocf.b);
// (the photograph is sampled linear: sRGB 72 is 0.06, 92 is 0.11)
forestW = smoothstep(0.12, 0.06, max(ocf.r, max(ocf.g, ocf.b))) * smoothstep(0.004, 0.012, gx) * smoothstep(u_lake_level + 0.8, u_lake_level + 1.8, p.y);
// Classify from a ~60 m blur, never from the pixels: the survey's 10 m pixels carry
// a foot trail as a broken line of bare ground, and thresholding them painted it
// across the meadow as tan dashes (and, on the CPU, lined boulders up along it).
vec3 ocl = textureLod(u_ortho, v_huv, 2.5).rgb;
float mxc = max(ocl.r, max(ocl.g, ocl.b)), mnc = min(ocl.r, min(ocl.g, ocl.b));
float greenEx = ocl.g - max(ocl.r, ocl.b);
screeC = smoothstep(0.008, -0.002, greenEx) * smoothstep(0.06, 0.12, mxc) * (1.0 - smoothstep(0.55, 0.75, mxc)) * smoothstep(u_lake_level + 0.2, u_lake_level + 1.2, p.y);
snowC = smoothstep(0.08, 0.04, mxc - mnc) * smoothstep(0.55, 0.8, mxc);
}
// snow lingering in the high gullies is drawn further down, but whether it can be
// there at all is known now, and it is the third caller of the snow sample
float gullyGate = smoothstep(450.0, 650.0, p.y) * smoothstep(0.75, 0.35, slope);
// ---- samples ---------------------------------------------------------------------
// World-space derivatives, taken once and in unbranched control flow: every sample
// below is in a branch and takes its gradients from these.
vec3 dpx = dFdx(p), dpy = dFdy(p);
vec3 gA = vec3(0.3, 0.4, 0.2), gN = vec3(0.0, 0.0, 1.0), gR = vec3(1.0, 0.8, 0.0);
vec3 rA = vec3(0.3), rN = vec3(0.0, 1.0, 0.0), rR = vec3(1.0, 0.8, 0.0);
vec3 sA = vec3(0.86, 0.88, 0.92), sN = vec3(0.0, 0.0, 1.0), sR = vec3(1.0, 0.55, 0.0);
vec3 pA, pN, pR;
vec2 uvg = p.xz * 0.28;
vec2 duvgx = dpx.xz * 0.28, duvgy = dpy.xz * 0.28;
// the grass carries the path too (the track is worn into it), and the forest duff
float needGrass = max(grassW, pathW);
float needRock = max(rockW, screeC);
float needSnow = max(snowW, max(snowC, gullyGate));
#ifdef TFAST_3
needGrass = 0.0; needRock = 0.0;
#endif
if (needGrass > 0.002) {
// dry / lush variation across the meadow (read only here and by the carpet below)
float lush = fbmC(cheap, p.xz * 0.006 + 2.0, 2) * 0.5 + 0.5;
matC(cheap, u_grass_d, u_grass_n, u_grass_a, uvg, duvgx, duvgy, gA, gN, gR);
// tint the grass by lushness
gA *= mix(vec3(0.42, 0.55, 0.3), vec3(0.28, 0.55, 0.25), lush) * 0.5;
// sun-facing slopes (south, +z) dry out lighter and warmer; shaded faces stay deep green
gA *= mix(vec3(0.85, 0.92, 0.9), vec3(1.12, 1.06, 0.82), smoothstep(-0.35, 0.35, N.z));
// beyond the blade rings the ground itself carries the clumps: the same cards, seen from above
// Also under the near blades, at reduced weight: the ground seen between standing
// blades must carry the same hue as the carpet that replaces them further out, or
// the field turns from grey-beige to green along a line that walks with the viewer.
float carpetW = mix(0.55, 1.0, smoothstep(8.0, 45.0, dist)) * smoothstep(0.7, 0.35, slope) * grassW;
#ifdef TFAST_3
carpetW = 0.0;
#endif
if (u_carpet_on > 0.5 && carpetW > 0.002) {
vec4 cp = carpetC(cheap, p.xz / 6.0, dpx.xz / 6.0, dpy.xz / 6.0);
// the standing blades in front of it are self-shaded: the carpet is held darker to match them
vec3 cc = cp.rgb * vec3(0.5, 0.57, 0.45) * mix(vec3(0.85, 0.92, 0.9), vec3(1.1, 1.05, 0.85), smoothstep(-0.35, 0.35, N.z)) * (0.75 + 0.35 * lush);
gA = mix(gA, cc, max(cp.a, 0.35) * carpetW * 0.97);
}
// Subalpine forest floor: dark duff where the stands are dense. The photograph-driven
// term marks duff only where the survey actually shows dense conifer, and is the same
// at any distance — ground shading must not depend on where the viewer is.
gA = mix(gA, vec3(0.045, 0.06, 0.025), forestW * 0.85);
}
pA = gA * vec3(0.95, 0.82, 0.62); // the same ground, worn to earth
pN = gN; pR = vec3(0.9, 0.85, 0.0);
// the scanned cliff face on the steep, high slopes; scree below
// The cliff sample and the relief/cliff blend that used to sit here wrote rA/rN/rR
// and were then overwritten wholesale by the rock sample below — they never reached
// the screen (removing them is pixel-identical). Deleting them frees the two sampler
// slots the histogram LUT needs; this shader was at the hardware limit of 16.
if (needRock > 0.002) {
triC(cheap, u_rock_d, u_rock_n, u_rock_a, p, dpx, dpy, N, 0.12, rA, rN, rR);
// macro rock structure for the mountains: a coarse second tile, strata darkening, blue-grey shade side
vec2 uv2 = p.xz * 0.006 + p.y * 0.002;
vec3 rA2 = textureGrad(u_rock_d, uv2, dpx.xz * 0.006 + dpx.y * 0.002, dpy.xz * 0.006 + dpy.y * 0.002).rgb;
vec3 rN2 = textureGrad(u_rock_n, p.zy * 0.01, dpx.zy * 0.01, dpy.zy * 0.01).rgb * 2.0 - 1.0;
rA = mix(rA, rA * rA2 * 2.2, 0.35) * (0.9 + 0.2 * fbmC(cheap, vec2(p.y * 0.03, p.x * 0.004 + p.z * 0.004), 3));
// the Bells' sedimentary strata: near-horizontal bands, tilted a little, sharper on the cliffs
float strata = 0.5 + 0.5 * sin(p.y * 0.45 + p.x * 0.012 + 3.0 * fbmC(cheap, p.xz * 0.01, 2));
rA *= mix(1.0, 0.75 + 0.5 * smoothstep(0.35, 0.65, strata), 0.5 * smoothstep(0.3, 0.6, slope));
rN = normalize(rN + vec3(rN2.x, 0.0, rN2.y) * 0.6 * smoothstep(80.0, 400.0, dist));
// the Bells are maroon mudstone: warm red-brown rock with grey scree below
rA *= mix(vec3(0.14, 0.08, 0.06), vec3(0.24, 0.15, 0.11), fbmC(cheap, p.xz * 0.003, 2) * 0.5 + 0.5);
}
if (needSnow > 0.002) {
sA = textureGrad(u_snow_d, p.xz * 0.25, dpx.xz * 0.25, dpy.xz * 0.25).rgb * 0.8;
}
// ---- blend (height-ish: sharpen with the weights) ----
vec3 alb = gA * grassW + pA * pathW + rA * rockW + sA * snowW;
// the photographed surface (a satellite image of this ground) takes over with distance,
// keeping the scanned materials' fine luminance detail so the middle ground still has grain
float screeMix = 0.0;
if (orthoOn) {
if (screeC > 0.002) {
// near the camera the scanned rocks take the scree at cobble scale (the far talus keeps the coarse tile)
float pebW = smoothstep(220.0, 40.0, dist);
vec3 pebA = vec3(0.36, 0.35, 0.33), pebN = vec3(0.0, 0.0, 1.0);
if (pebW > 0.002) {
pebA = textureGrad(u_rock_d, p.xz * 0.55, dpx.xz * 0.55, dpy.xz * 0.55).rgb * vec3(0.36, 0.35, 0.33);
pebN = textureGrad(u_rock_n, p.xz * 0.55, dpx.xz * 0.55, dpy.xz * 0.55).rgb * 2.0 - 1.0;
}
vec3 screeA = mix(rA * vec3(1.25, 1.2, 1.15), pebA * (0.75 + 0.5 * fbmC(cheap, p.xz * 0.15, 2)), pebW);
// the 10 m photo pixels blur turf and gravel together on the shore: keep grass showing between the cobbles
alb = mix(alb, screeA, screeC * (1.0 - rockW) * mix(0.55, 0.9, smoothstep(30.0, 200.0, dist)));
rN = normalize(mix(rN, normalize(vec3(pebN.x, 1.0, pebN.y)), screeC * pebW * 0.8));
screeMix = screeC;
}
alb = mix(alb, sA, snowC * 0.9);
float lumA = dot(alb, vec3(0.3, 0.59, 0.11));
// How much of the photograph shows through. This was smoothstep(260, 800, dist) —
// ground colour cross-fading toward the survey image as it receded from the camera.
// The photograph has shadows and dark vegetation baked into it from the day it was
// flown, so grass that was plain up close grew dark patches as you backed away, and
// those patches slid and changed shape as you walked. A constant keeps the
// photograph's large-scale colour without tying any of it to the camera.
float orthoW = 0.55;
// grain at three scales so the far slopes keep structure the photograph's pixels cannot carry
#ifdef TFAST_20
float grain = 0.82;
#else
float grain = 0.82 + 0.36 * fbmC(cheap, p.xz * 0.7, 2) + 0.12 * fbmC(cheap, p.xz * 4.0, 2) + 0.3 * (fbmC(cheap, p.xz * 0.06 + 5.0, 3) - 0.5) + 0.15 * (ridgedC(cheap, p.xz * 0.02 + 9.0, 2) - 0.5);
#endif
alb = mix(alb, oc * (0.35 + 1.4 * lumA / max(lumA + 0.12, 1e-3)) * grain, orthoW);
}
// ---- the shoreline, continued onto the land ------------------------------------
// A flat water plane cutting a slope meets it along one exact contour, and no amount
// of shading on the water side removes a mathematically sharp line. The transition
// has to be drawn on BOTH surfaces, so the same wash the water runs is continued up
// the bank here: identical noise fields, identical time, identical phase, keyed off
// height above the lake instead of depth below it. Across the seam the two agree, so
// there is nothing there to read as an edge.
float above = p.y - u_lake_level; // >0 on land, metres
// wet ground: darker and glossier near the water, fading out over ~1.2 m
float wet = smoothstep(1.2, 0.0, above);
alb *= mix(1.0, 0.5, wet * 0.85);
// A sheet of water actually runs up the bank ahead of the foam, so the ground inside
// the wash is seen through water, not bare. Without this the gaps between the foam
// streaks showed dry grass and the wash looked like white paint on a lawn.
float film = smoothstep(0.4, 0.0, above);
vec3 shoreWater = vec3(0.05, 0.11, 0.13) * skyIrradiance(vec3(0, 1, 0)) * 1.15;
alb = mix(alb, mix(alb * 0.5, shoreWater, 0.45), film);
// The wash itself: the water's lap, run above the line and fading as it climbs — the
// same fields, octaves and phase the water uses, so the two agree across the seam.
// Its two gates — the last 22 cm above the waterline, and the first 120 m from the
// camera — are pure geometry, and outside them the four noise fields behind it cannot
// reach the screen. They are worth testing first: the wash is a hairline along one
// shore and the fields were being evaluated for every pixel of the valley.
float washGate = smoothstep(0.22, 0.0, above) * smoothstep(120.0, 15.0, dist);
if (washGate > 0.002) {
float lapT = 0.5 + 0.5 * sin(-above * 9.0 - u_time * 1.6 + 2.0 * gnoiseC(cheap, p.xz * 0.8 + u_time * 0.2));
float fdetT = fbmC(cheap, p.xz * 7.0 - u_time * 0.35, 3) * 0.5 + 0.5;
float fmidT = fbmC(cheap, p.xz * 2.6 + u_time * 0.5, 3) * 0.5 + 0.5;
float fedgeT = fbmC(cheap, p.xz * 1.4 - u_time * 0.3, 2) * 0.5 + 0.5;
// a still alpine lake has a wet line, not surf: keep the wash thin and faint
float fringe = washGate
* (0.12 * smoothstep(0.30, 0.72, fmidT)
+ 0.10 * smoothstep(0.55, 0.95, lapT) * smoothstep(0.22, 0.6, fedgeT)) * (0.55 + 0.75 * fdetT);
alb = mix(alb, vec3(0.72, 0.76, 0.76), clamp(fringe, 0.0, 1.0));
}
// snow lingering in the high gullies (the July photograph's white streaks)
float gully = 0.0;
if (gullyGate > 0.002) { gully = smoothstep(0.62, 0.85, ridgedC(cheap, p.xz * 0.02 + 3.0, 2)) * gullyGate; }
alb = mix(alb, sA * 1.05, gully * 0.9);
vec3 arm = gR * grassW + pR * pathW + rR * rockW + sR * snowW;
// world tangent frame for the planar maps
vec3 T = normalize(vec3(1.0, 0.0, 0.0) - N * N.x);
vec3 B = cross(N, T);
vec3 tn = normalize(gN * grassW + pN * pathW + sN * snowW + vec3(0, 0, 1e-3));
vec3 nPlanar = normalize(T * tn.x + B * tn.y + N * tn.z);
vec3 n = normalize(mix(nPlanar, rN, max(rockW, screeMix * 0.6)));
// fade the detail normal with distance so the far terrain does not sparkle
n = normalize(mix(n, N, smoothstep(150.0, 900.0, dist)));
float ao = arm.r;
float rough = clamp(arm.g, 0.3, 1.0);
float metal = 0.0;
// the shadow map carries the objects standing on the ground (trees, rocks); the
// ground's own relief is in the baked height-field shadow that sunShadow() applies
#ifdef TFAST_1
float shadow = 1.0;
#else
// tersun.frag computed this for exactly this pixel; see the note there for why the
// cascade read cannot happen in here.
float shadow = texelFetch(u_sunshadow, ivec2(gl_FragCoord.xy), 0).r;
#endif
vec3 col = shade(p, n, alb, rough, metal, ao, shadow, viewDepth);
dbg_n = n; dbg_alb = alb; dbg_shadow = shadow; dbg_mat = vec3(rockW, grassW, snowW);
return col;
}
uniform float u_far_split;
uniform float u_far_band;
void main() {
vec3 p = v_wpos;
if (p.y < u_clip_y) discard;
vec3 N = terrainNormal(v_huv);
float slope = 1.0 - N.y;
float dist = length(p - u_cam_pos);
float viewDepth = -(u_view * vec4(p, 1.0)).z;
vec3 col;
#ifdef NEAR_ONLY
// The near program: this patch lies entirely inside the split, so only the detailed
// tier can run here. Compiled alone it does not have to hold the cheap tier's code
// beside it, which is what pushed the combined shader past the register budget.
col = groundShade(p, N, slope, dist, viewDepth, false);
#elif defined(FAR_ONLY)
// The far program: this patch is entirely beyond u_far_split + u_far_band, so every
// pixel in it would take the cheap tier anyway. Compiling that tier on its own — with
// no near path inlined beside it — is the whole point: the two tiers together put this
// shader over the register budget, and the far pixels (most of the screen: the valley
// walls and the Bells) were paying for a near path they never ran.
col = groundShade(p, N, slope, dist, viewDepth, true);
#elif defined(TFAST_5)
col = groundShade(p, N, slope, dist, viewDepth, false);
#else
float band = u_far_band;
if (dist > u_far_split + band) col = groundShade(p, N, slope, dist, viewDepth, true);
else if (dist < u_far_split - band) col = groundShade(p, N, slope, dist, viewDepth, false);
else col = mix(groundShade(p, N, slope, dist, viewDepth, false), groundShade(p, N, slope, dist, viewDepth, true), smoothstep(u_far_split - band, u_far_split + band, dist));
#endif
col = applyFog(col, p, dist);
#ifdef DEBUG_SHADOW
col = vec3(dbg_shadow);
#endif
#ifdef DEBUG_NRM
col = dbg_n * 0.5 + 0.5;
#endif
#ifdef DEBUG_MAT
col = dbg_mat;
#endif
#ifdef DEBUG_ALB
col = dbg_alb * 3.0;
#endif
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,50 @@
// CDLOD terrain (Strugar 2009): every draw is one 32x32 patch of the quadtree, placed
// and scaled by u_node. Toward the outer edge of its level's range each vertex morphs
// onto the parent level's grid (odd vertices slide to their even neighbours), so a patch
// meets its coarser neighbour edge-for-edge with no cracks and no popping. Height comes
// from one B-spline sample of the height field at the morphed position, so every level
// sits on the same continuous surface.
layout(location = 0) in vec2 a_xz; // 0..1 across the patch
uniform sampler2D u_height;
uniform float u_half;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform vec2 u_origin;
uniform vec3 u_cam_pos;
uniform vec3 u_node; // x0, z0, size (m)
uniform vec2 u_morph; // distance where the morph starts, and where it is complete
uniform float u_grid; // cells per patch side
float heightSmooth(sampler2D tex, vec2 uv) {
vec2 res = vec2(textureSize(tex, 0));
vec2 t = uv * res - 0.5;
vec2 f = fract(t);
vec2 i = floor(t);
vec2 w0 = (1.0 - f) * (1.0 - f) * (1.0 - f) / 6.0;
vec2 w1 = (4.0 - 6.0 * f * f + 3.0 * f * f * f) / 6.0;
vec2 w3 = f * f * f / 6.0;
vec2 w2 = 1.0 - w0 - w1 - w3;
vec2 s0 = w0 + w1, s1 = w2 + w3;
vec2 o0 = (i - 1.0 + w1 / s0 + 0.5) / res;
vec2 o1 = (i + 1.0 + w3 / s1 + 0.5) / res;
return (texture(tex, vec2(o0.x, o0.y)).r * s0.x + texture(tex, vec2(o1.x, o0.y)).r * s1.x) * s0.y
+ (texture(tex, vec2(o0.x, o1.y)).r * s0.x + texture(tex, vec2(o1.x, o1.y)).r * s1.x) * s1.y;
}
out vec3 v_wpos;
out vec2 v_huv;
void main() {
vec2 grid = a_xz * u_grid;
vec2 xz = u_node.xy + a_xz * u_node.z;
vec2 huv = (xz - u_origin) / (2.0 * u_half) + 0.5;
float h0 = texture(u_height, huv).r;
float d = distance(vec3(xz.x, h0, xz.y), u_cam_pos);
float k = clamp((d - u_morph.x) / max(u_morph.y - u_morph.x, 1.0), 0.0, 1.0);
vec2 frac2 = fract(grid * 0.5) * 2.0; // 1 on odd vertices
grid -= frac2 * k;
xz = u_node.xy + grid / u_grid * u_node.z;
huv = (xz - u_origin) / (2.0 * u_half) + 0.5;
float h = heightSmooth(u_height, huv);
vec3 p = vec3(xz.x, h, xz.y);
v_wpos = p;
v_huv = huv;
gl_Position = u_proj * u_view * vec4(p, 1.0);
}

View file

@ -0,0 +1,39 @@
// Height-field sun shadow, baked once per sun direction (the sun is fixed per scene).
//
// For every height-map texel: the lowest height at which a point above that texel still
// sees the sun. A point (xz, y) is lit iff the ray toward the sun clears the ground
// everywhere along it, i.e. y > h(xz + d.xz t) - d.y t for all t — so the value stored
// is the maximum of that expression over the ray. Every receiver in the scene (ground,
// trunk, crown, card, water) compares its own height against it: one march per texel,
// once, instead of 28 taps per terrain pixel per frame, and vegetation standing in a
// hillside's shadow goes dark with the hillside instead of glowing in front of it.
// The second channel is the distance to the occluder that set the bound, which widens
// the penumbra the way a real shadow softens with distance from its caster.
in vec2 v_uv;
out vec4 o;
uniform sampler2D u_height;
uniform float u_half;
uniform vec3 u_sun;
void main() {
vec2 xz = (v_uv - 0.5) * 2.0 * u_half;
vec3 d = u_sun;
float lit = -1.0e6;
float at = 0.0;
if (d.y > 0.02) {
float t = 1.5, step = 1.5;
for (int i = 0; i < 128; i++) {
vec2 q = xz + d.xz * t;
vec2 uv = q / (2.0 * u_half) + 0.5;
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) break;
float h = texture(u_height, uv).r - d.y * t;
if (h > lit) { lit = h; at = t; }
t += step;
step *= 1.045;
}
}
// the cloud layer's mask, once, into B: sampled by cloudShadow() with the sun offset and
// the drift applied as a uv shift, instead of a five-octave fbm in every lit pixel of
// every pass
float cloud = smoothstep(0.02, 0.32, fbm(xz * 0.0011, 5));
o = vec4(lit, at, cloud, 1.0);
}

View file

@ -0,0 +1,31 @@
// tersun.frag — the terrain's sun visibility, on its own, one screen-sized R8 buffer.
//
// The ground's shading shader is large: it blends four scanned materials, a photograph
// and a dozen noise fields. Adding a read of the cascade shadow map to it costs about
// six milliseconds a frame on this driver — and costs the same whether the map is tapped
// once or eight times, filtered or texelFetched, compared in hardware or by hand. It is
// a cliff the big shader falls off, not work it performs. The same read from a small
// shader is nearly free, so the read happens here instead: this pass rasterises the same
// CDLOD patches, evaluates the cascades once per pixel, and writes the answer for
// terrain.frag to look up by fragment coordinate.
in vec3 v_wpos;
in vec2 v_huv;
out float o_sh;
uniform sampler2D u_height;
uniform mat4 u_view;
uniform float u_far_split;
uniform float u_far_band;
void main() {
vec3 p = v_wpos;
if (p.y < u_clip_y) discard;
vec3 N = normalize(texture(u_height, v_huv).gba);
float dist = length(p - u_cam_pos);
float viewDepth = -(u_view * vec4(p, 1.0)).z;
// The same tier choice the ground makes, cross-faded over the same band: the near tier
// keeps its filtered penumbra, the far tier its single tap, and the boundary between
// them is not a contour you can find on the hillside.
if (dist > u_far_split + u_far_band) o_sh = sunShadowCheap(p, N, viewDepth);
else if (dist < u_far_split - u_far_band) o_sh = sunShadow(p, N, viewDepth);
else o_sh = mix(sunShadow(p, N, viewDepth), sunShadowCheap(p, N, viewDepth),
smoothstep(u_far_split - u_far_band, u_far_split + u_far_band, dist));
}

View file

@ -0,0 +1,47 @@
// exposure -> ACES -> vignette -> sRGB, with dithering
in vec2 v_uv;
out vec4 o_color;
uniform sampler2D u_hdr;
uniform sampler2D u_bloom;
uniform sampler2D u_ao;
uniform float u_ao_strength;
uniform float u_gi_strength;
uniform vec3 u_wb; // white balance multiplier
uniform vec3 u_lift;
uniform vec3 u_gain;
uniform float u_exposure;
uniform sampler2D u_adapt; // the GPU's adapted exposure (adapt.frag), 1x1
uniform float u_auto; // 1: use it, 0: u_exposure as set
uniform float u_bloom_strength;
uniform float u_vignette;
uniform float u_saturation;
uniform float u_contrast;
vec3 aces(vec3 x) {
const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec3 hdr = sane(texture(u_hdr, v_uv).rgb);
vec4 gi = texture(u_ao, v_uv);
hdr *= mix(1.0, gi.a, u_ao_strength);
// the indirect bounce arrives in the surface's own hue (no albedo buffer in a forward renderer)
float l = dot(hdr, vec3(0.2126, 0.7152, 0.0722));
hdr += gi.rgb * (hdr / max(l, 1e-3)) * u_gi_strength;
vec3 bloom = texture(u_bloom, v_uv).rgb;
float exposure = mix(u_exposure, texture(u_adapt, vec2(0.5)).r, u_auto);
vec3 c = (hdr + bloom * u_bloom_strength) * exposure * u_wb;
// filmic contrast around mid grey in log space
c = max(c, vec3(0.0));
c = pow(c / 0.18, vec3(u_contrast)) * 0.18;
c = aces(c);
// lift / gain grade in display space
c = c * u_gain + u_lift * (1.0 - c);
float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = mix(vec3(lum), c, u_saturation);
vec2 q = v_uv * 2.0 - 1.0;
c *= 1.0 - u_vignette * dot(q, q) * 0.5;
c = pow(c, vec3(1.0 / 2.2));
c += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
o_color = vec4(c, 1.0);
}

View file

@ -0,0 +1,107 @@
// still water: sky reflection with fresnel, sun glitter, scrolling ripple normals, absorption colour
in vec3 v_wpos;
out vec4 o_color;
uniform mat4 u_view;
uniform sampler2D u_depth; // scene depth (resolved) for shore softness / depth tint
uniform mat4 u_inv_vp;
uniform vec2 u_screen;
uniform sampler2D u_refl; // the world mirrored in the surface (rendered by the reflection pass)
uniform float u_refl_on;
uniform sampler2D u_scene; // the scene as drawn before the water: the bed, to refract
// wind-streaked capillary ripples (stretched along the wind) over slower swells
float waterH(vec2 p, float t) {
vec2 w = vec2(p.x * 0.7 + p.y * 0.15, p.y * 1.4) ; // mildly anisotropic: cat's-paws stretched along the wind
// calmer water: the swell keeps most of its weight, the two ripple octaves are
// pulled well down so the surface reads as a lake rather than a chop
return 0.4 * gnoise(w * 0.9 + vec2(t * 0.06, t * 0.4)) + 0.16 * gnoise(p * 2.3 - vec2(t * 0.05, -t * 0.07)) + 0.07 * gnoise(p * 6.0 + vec2(t * 0.9, t * 0.3));
}
vec3 rippleNormal(vec2 p, float t) {
float e = 0.06;
float h = waterH(p, t), hx = waterH(p + vec2(e, 0), t), hz = waterH(p + vec2(0, e), t);
return normalize(vec3(-(hx - h) * 0.26 / e, 1.0, -(hz - h) * 0.26 / e));
}
void main() {
vec3 v = normalize(u_cam_pos - v_wpos);
float dist = length(u_cam_pos - v_wpos);
vec3 n = rippleNormal(v_wpos.xz, u_time);
n = normalize(mix(n, vec3(0, 1, 0), smoothstep(100.0, 600.0, dist))); // calm at a distance
// how deep the ground is under this pixel: from the scene depth
vec2 suv = gl_FragCoord.xy / u_screen;
float sd = texture(u_depth, suv).r;
vec4 gp = u_inv_vp * vec4(suv * 2.0 - 1.0, sd * 2.0 - 1.0, 1.0);
vec3 ground = gp.xyz / gp.w;
float depthBelow = clamp(v_wpos.y - ground.y, 0.0, 10.0);
// How opaque the water is at the shoreline. This used to fade over the last 1.2 m of
// depth, which is the same band the foam lives in, so the surface went transparent
// exactly where it should have been breaking white: the foam was drawn and then
// alpha'd away, leaving a gap of dark wet ground and water that looked like it
// stopped short of the bank. Fade over a much shorter distance so the water reaches
// the edge, and let the foam carry its own opacity below.
vec3 r = reflect(-v, n);
r.y = abs(r.y);
vec3 refl = skyPrefiltered(r, 0.12);
if (u_refl_on > 0.5) {
// the mirrored render lines up with the screen; the ripples nudge and soften the lookup
vec2 ruv = suv + n.xz * 0.02 * smoothstep(500.0, 20.0, dist);
float blur = mix(0.5, 0.2, smoothstep(0.0, 300.0, dist));
refl = sane(textureLod(u_refl, clamp(ruv, 0.001, 0.999), blur).rgb);
}
// wind-blown foam streaks and shoreline wash
float foam = smoothstep(0.62, 0.9, gnoise(vec2(v_wpos.x * 0.25 + u_time * 0.3, v_wpos.z * 1.5) ) * 0.5 + 0.5) * 0.03 * smoothstep(200.0, 30.0, dist);
// Wash: the shallows lapping the shore. Built from fbm rather than one gnoise octave —
// a single octave is a blobby lattice that magnifies into visible squares when you
// stand next to it, which is what made the wash read as cartoon cut-outs. Several
// octaves plus a fine breakup term give it structure at every range it is seen from.
float lap = 0.5 + 0.5 * sin(depthBelow * 9.0 - u_time * 1.6 + 2.0 * gnoise(v_wpos.xz * 0.8 + u_time * 0.2));
float fdet = fbm(v_wpos.xz * 7.0 - u_time * 0.35, 3) * 0.5 + 0.5; // fine bubbles
float fmid = fbm(v_wpos.xz * 2.6 + u_time * 0.5, 3) * 0.5 + 0.5;
float fedge = fbm(v_wpos.xz * 1.4 - u_time * 0.3, 2) * 0.5 + 0.5;
// a still alpine lake has a wet line, not surf: the wash is thin (the last 0.35 m of
// depth) and faint, and the terrain runs the same fields at the same strength
foam += smoothstep(0.35, 0.0, depthBelow) * (0.12 * smoothstep(0.30, 0.72, fmid) + 0.10 * smoothstep(0.55, 0.95, lap) * smoothstep(0.22, 0.6, fedge)) * (0.55 + 0.75 * fdet);
// the lap is a near-field detail: from a distance a lake's edge is a line, not a surf
foam *= smoothstep(120.0, 15.0, dist);
// the wash dies where the surface meets the ground, so it cannot end on a hard line
foam *= smoothstep(0.0, 0.5, length(ground - v_wpos));
float NoV = max(dot(n, v), 0.0);
float F = 0.02 + 0.98 * pow(1.0 - NoV, 5.0);
vec3 hv = normalize(v + u_sun_dir);
float NoH = max(dot(n, hv), 0.0);
float glitter = D_GGX(NoH, 0.06) * 0.25;
float viewDepth = -(u_view * vec4(v_wpos, 1.0)).z;
float shadow = sunShadow(v_wpos, vec3(0, 1, 0), viewDepth) * cloudShadow(v_wpos);
// ---- what is under the surface -------------------------------------------------
// The bed is sampled from the scene as it was drawn before the water, nudged by the
// ripple normal (refraction), then attenuated per channel over the path the light
// actually travelled: down through the water and back up to the eye. Red goes first,
// then green, so shallows stay bright and readable and depth turns blue-green and
// dark on its own. This is what makes it a body of water rather than a tinted sheet:
// the ground is seen through it, not behind it.
vec2 ruv2 = clamp(suv + n.xz * 0.03 * smoothstep(0.0, 2.0, depthBelow), 0.001, 0.999);
// never refract something that is actually in front of the surface (the near bank),
// or the grass on the shore smears out over the water
float rd = texture(u_depth, ruv2).r;
vec4 rgp = u_inv_vp * vec4(ruv2 * 2.0 - 1.0, rd * 2.0 - 1.0, 1.0);
vec3 rground = rgp.xyz / rgp.w;
if (rground.y > v_wpos.y) { ruv2 = suv; }
vec3 bed = sane(texture(u_scene, ruv2).rgb);
float pathLen = depthBelow * (1.0 + 1.0 / max(NoV, 0.25));
vec3 absorb = vec3(0.55, 0.24, 0.14); // per metre: red first, then green — a cold blue-teal depth
vec3 trans = exp(-absorb * pathLen);
vec3 tint = vec3(0.030, 0.085, 0.105) * skyIrradiance(vec3(0, 1, 0)) * 1.15; // Maroon Lake: deep, dark blue-green, not turquoise
vec3 through = bed * trans + tint * (1.0 - trans);
// ---- surface -------------------------------------------------------------------
vec3 col = mix(through, refl, clamp(F * 1.1 + 0.05, 0.0, 0.86)) + u_sun_color * glitter * F * shadow;
col = mix(col, vec3(0.7, 0.75, 0.75) * (skyIrradiance(vec3(0, 1, 0)) * 0.5 + u_sun_color * 0.08 * shadow), clamp(foam, 0.0, 1.0));
col = applyFog(col, v_wpos, dist);
// Soft edge measured ALONG THE VIEW RAY, not vertically. Vertical depth collapses to
// zero over a fraction of a pixel when the surface is seen edge-on, which is exactly
// the low, near-the-waterline view where the plane's silhouette turns into a hard
// glassy line. The distance from the surface to the bed along the ray stays a smooth
// quantity at any angle, so the water dissolves into the ground it meets instead.
float alongRay = length(ground - v_wpos);
float soft = smoothstep(0.0, 0.5, alongRay);
col = mix(bed, col, soft);
o_color = vec4(sane(col), 1.0);
}

View file

@ -0,0 +1,12 @@
layout(location = 0) in vec2 a_xz;
uniform mat4 u_view;
uniform mat4 u_proj;
uniform float u_level;
uniform vec2 u_center;
uniform vec2 u_extent;
out vec3 v_wpos;
void main() {
vec3 p = vec3(u_center.x + a_xz.x * 2.0 * u_extent.x, u_level, u_center.y + a_xz.y * 2.0 * u_extent.y); // the grid spans ±0.5
v_wpos = p;
gl_Position = u_proj * u_view * vec4(p, 1.0);
}

View file

@ -0,0 +1,250 @@
# ============================================================================
# shadow.ludic — cascaded shadow maps for the sun: four 2048^2 depth layers,
# each an orthographic light frustum fitted to the bounding sphere of a slice
# of the camera frustum and snapped to its own texel grid (no swimming).
# ============================================================================
const SHADOW_RES: int = 2048
const SHADOW_CASCADES: int = 5
var sh_tex: int = 0
var sh_fbo: int = 0
var sh_vp: words = null # 4 x 16 float bits
var sh_split: words = null # view-space far distance of each cascade
var sh_range: words = null # 4 light-frustum depth extents (metres)
var sh_texel: words = null # 4 shadow texel sizes (metres)
var sh_tmp_proj: words = null
var sh_tmp_vp: words = null
var sh_tmp_inv: words = null
var sh_tmp_view: words = null
var sh_corner: words = null
var sh_cascade: int = 0 # the cascade being rendered (for casters that skip far ones)
function shadow_init() -> void {
sh_tex = gl_texture()
gl_bind_texture(GL_TEXTURE_2D_ARRAY, sh_tex)
gl_tex_image3d(GL_TEXTURE_2D_ARRAY, 0, GL_DEPTH_COMPONENT32F, SHADOW_RES, SHADOW_RES, SHADOW_CASCADES, 0, GL_DEPTH_COMPONENT, GL_FLOAT, null)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL)
let border = gl_floats(4)
gl_put(border, 0, 1.0); gl_put(border, 1, 1.0); gl_put(border, 2, 1.0); gl_put(border, 3, 1.0)
gl_tex_parameterfv(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_BORDER_COLOR, border)
free(border)
sh_fbo = gl_framebuffer()
gl_bind_framebuffer(GL_FRAMEBUFFER, sh_fbo)
gl_draw_buffer(GL_NONE)
gl_read_buffer(GL_NONE)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
sh_vp = words(16 * SHADOW_CASCADES)
sh_split = words(SHADOW_CASCADES)
sh_range = words(SHADOW_CASCADES)
sh_texel = words(SHADOW_CASCADES)
# the fourth slice keeps a tree-sized texel out to a kilometre; only the massif uses the last
sh_split[0] = fi(16); sh_split[1] = fi(60); sh_split[2] = fi(250); sh_split[3] = fi(1100); sh_split[4] = fi(6000)
sh_tmp_proj = m4_new(); sh_tmp_vp = m4_new(); sh_tmp_inv = m4_new(); sh_tmp_view = m4_new()
sh_corner = words(3)
}
# light view-projection for the camera-frustum slice [near, far]
function shadow_fit(c: int, near: int, far: int) -> void {
# Fit the slice in VIEW space, not world space. The bounding sphere of a frustum
# slice depends only on near/far/fov/aspect — never on where the camera is pointing —
# so computing it here makes the radius a constant per cascade. Doing it in world
# space (as this did) let the radius wobble as the camera turned, which changed the
# texel size, which moved the grid the projection is snapped to, so the whole shadow
# map resampled every frame: that is the crawl and flicker seen while moving.
m4_perspective(sh_tmp_proj, cam_fov, cam_aspect, near, far)
m4_inverse(sh_tmp_inv, sh_tmp_proj) # NDC -> view space
let cview = v3_new(F_ZERO, F_ZERO, F_ZERO)
let corners = words(24)
for i in 0 .. 8 {
var x = f_neg1(); var y = f_neg1(); var z = f_neg1()
if (i & 1) != 0 { x = F_ONE }
if (i & 2) != 0 { y = F_ONE }
if (i & 4) != 0 { z = F_ONE }
let w = m4_xform_point(sh_corner, sh_tmp_inv, x, y, z)
let iw = f_div(F_ONE, w)
corners[i * 3] = f_mul(sh_corner[0], iw); corners[i * 3 + 1] = f_mul(sh_corner[1], iw); corners[i * 3 + 2] = f_mul(sh_corner[2], iw)
cview[0] = f_add(cview[0], corners[i * 3]); cview[1] = f_add(cview[1], corners[i * 3 + 1]); cview[2] = f_add(cview[2], corners[i * 3 + 2])
}
v3_scale(cview, cview, fr(1, 8))
var radius = F_ZERO
for i in 0 .. 8 {
v3_set(sh_corner, corners[i * 3], corners[i * 3 + 1], corners[i * 3 + 2])
let d = v3_dist(sh_corner, cview)
if f_gt(d, radius) { radius = d }
}
radius = f_mul(radius, fl(1.05))
# the slice centre back into world space
m4_inverse(sh_tmp_vp, cam_view)
let center = words(3)
m4_xform_point(center, sh_tmp_vp, cview[0], cview[1], cview[2])
free(cview)
# light view: from far along the sun direction, looking at the centre
let eye = words(3)
# casters up to ~900 m toward the sun (a mountain across the valley), and the
# slice itself behind the centre: a tight depth range keeps the bias small
let back = f_add(radius, fi(900))
v3_madd(eye, center, sun_dir, back)
let up = v3_new(F_ZERO, F_ONE, F_ZERO)
m4_look_at(sh_tmp_view, eye, center, up)
# snap the ortho window to the shadow texel grid
let texel = f_div(f_mul(radius, F_TWO), fi(SHADOW_RES))
m4_xform_point(sh_corner, sh_tmp_view, center[0], center[1], center[2])
let ox = f_sub(f_mul(f_floor(f_div(sh_corner[0], texel)), texel), sh_corner[0])
let oy = f_sub(f_mul(f_floor(f_div(sh_corner[1], texel)), texel), sh_corner[1])
let nr = f_neg(radius)
let zfar = f_add(f_add(back, radius), fi(100))
m4_ortho(sh_tmp_proj, f_add(nr, ox), f_add(radius, ox), f_add(nr, oy), f_add(radius, oy), F_ONE, zfar)
sh_range[c] = f_sub(zfar, F_ONE)
sh_texel[c] = texel
let out = words(16)
m4_mul(out, sh_tmp_proj, sh_tmp_view)
for i in 0 .. 16 { sh_vp[c * 16 + i] = out[i] }
free(out); free(eye); free(up); free(center); free(corners)
}
function shadow_cascade_vp(c: int) -> words { return mem_off(sh_vp, c * 64) }
# render every cascade; `draw` happens through terrain_draw_shadow + the scene's casters
function shadow_pass() -> void {
var near = cam_near
gl_bind_framebuffer(GL_FRAMEBUFFER, sh_fbo)
gl_viewport(0, 0, SHADOW_RES, SHADOW_RES)
gl_enable(GL_DEPTH_TEST)
gl_depth_func(GL_LESS)
gl_enable(GL_POLYGON_OFFSET_FILL)
gl_polygon_offset(2.0, 4.0)
gl_disable(GL_CULL_FACE)
for c in 0 .. SHADOW_CASCADES {
sh_cascade = c
shadow_fit(c, near, sh_split[c])
near = sh_split[c]
gl_framebuffer_texture_layer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, sh_tex, 0, c)
if r3d_debug and c == 0 { let st = gl_check_framebuffer_status(GL_FRAMEBUFFER); print(`shadow fbo status {st}`) }
gl_clear(GL_DEPTH_BUFFER_BIT)
let vp = shadow_cascade_vp(c)
# shadows off (a video setting): the cascades stay cleared, so everything reads lit
if sh_enabled {
if not sh_skip_terrain { terrain_draw_shadow(vp) }
scene_draw_casters(vp)
}
}
gl_disable(GL_POLYGON_OFFSET_FILL)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
if r3d_debug_shadow { shadow_dump() }
if r3d_debug_shadow and not sh_printed2 {
sh_printed2 = true
let q = words(3)
if sh_probe_x != 0 {
let vp = shadow_cascade_vp(2)
m4_xform_point(q, vp, sh_probe_x, sh_probe_y, sh_probe_z)
print(`probe base ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])}`)
m4_xform_point(q, vp, sh_probe_x, f_add(sh_probe_y, fi(15)), sh_probe_z)
print(`probe top ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])} -> map texel {f_to_int(f_mul(f_add(f_mul(q[0], F_HALF), F_HALF), fi(SHADOW_RES)))} {f_to_int(f_mul(f_add(f_mul(q[1], F_HALF), F_HALF), fi(SHADOW_RES)))}`)
# where the top's shadow lands on the ground: walk down the sun ray
let gx = f_sub(f_add(sh_probe_x, F_ZERO), f_mul(sun_dir[0], f_div(fi(15), sun_dir[1])))
let gz = f_sub(sh_probe_z, f_mul(sun_dir[2], f_div(fi(15), sun_dir[1])))
m4_xform_point(q, vp, gx, terrain_height(gx, gz), gz)
print(`shadow-of-top ground ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])} at {f_fx(gx)} {f_fx(gz)}`)
}
for c in 0 .. SHADOW_CASCADES {
let vp = shadow_cascade_vp(c)
# a point 5 m ahead of the camera on the ground
let px = f_add(cam_pos[0], f_mul(cam_fwd[0], fi(5))); let pz = f_add(cam_pos[2], f_mul(cam_fwd[2], fi(5)))
let w = m4_xform_point(q, vp, px, terrain_height(px, pz), pz)
print(`cascade {c}: ndc {f_fx(q[0])} {f_fx(q[1])} {f_fx(q[2])} w {f_fx(w)} m0 {f_fx(vp[0])} m5 {f_fx(vp[5])} m14 {f_fx(vp[14])}`)
}
free(q)
}
}
var sh_printed2: bool = false
var sh_printed3: bool = false
var sh_enabled: bool = true
var sh_force: int = -1 # R3D_FORCE=<c> pins every pixel to cascade c (debug)
var sh_skip_terrain: bool = false
var sh_probe_x: int = 0
var sh_probe_y: int = 0
var sh_probe_z: int = 0
# Debug: cascade depths as grey PPMs (build/dbg_shadow_<c>.ppm)
function shadow_dump() -> void {
let n = SHADOW_RES * SHADOW_RES
let buf = words(n * SHADOW_CASCADES)
gl_bind_texture(GL_TEXTURE_2D_ARRAY, sh_tex)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_COMPARE_MODE, GL_NONE)
gl_get_tex_image(GL_TEXTURE_2D_ARRAY, 0, GL_DEPTH_COMPONENT, GL_FLOAT, buf)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE)
if sh_probe_x != 0 {
let vp = shadow_cascade_vp(2)
let q = words(3)
m4_xform_point(q, vp, sh_probe_x, f_add(sh_probe_y, fi(12)), sh_probe_z)
let tx = f_to_int(f_mul(f_add(f_mul(q[0], F_HALF), F_HALF), fi(SHADOW_RES)))
let ty = f_to_int(f_mul(f_add(f_mul(q[1], F_HALF), F_HALF), fi(SHADOW_RES)))
let want = f_add(f_mul(q[2], F_HALF), F_HALF)
print(`probe (12 m up) texel {tx} {ty} card depth {f_fx(f_mul(want, fi(1000)))}/1000`)
for dy in 0 .. 5 {
let yy = ty - 40 + dy * 20
print(` row {yy}: {f_fx(f_mul(buf[2 * n + yy * SHADOW_RES + tx - 20], fi(1000)))} {f_fx(f_mul(buf[2 * n + yy * SHADOW_RES + tx], fi(1000)))} {f_fx(f_mul(buf[2 * n + yy * SHADOW_RES + tx + 20], fi(1000)))} /1000`)
}
free(q)
}
let sm = 512
let row = bytes(sm * 3)
for c in 0 .. SHADOW_CASCADES {
# stretch between the map's own min and max (ignoring the far plane)
var lo = F_ONE; var hi = F_ZERO
var i = 0
while i < n { let d = buf[c * n + i]; if f_ls(d, fl(0.999)) { if f_ls(d, lo) { lo = d }; if f_gt(d, hi) { hi = d } }; i += 97 }
print(`cascade {c} depth range {f_fx(lo)} .. {f_fx(hi)}`)
let f = file_open(`build/dbg_shadow_{c}.ppm`, "wb")
let hdr = `P6\n{sm} {sm}\n255\n`
file_write(f, hdr, len(hdr))
let st = SHADOW_RES / sm
for y in 0 .. sm {
for x in 0 .. sm {
let d = buf[c * n + (y * st) * SHADOW_RES + x * st]
let g = f_to_int(f_mul(f_clamp(f_div(f_sub(d, lo), f_max(f_sub(hi, lo), fl(0.0001))), F_ZERO, F_ONE), fi(255)))
row[x * 3] = g; row[x * 3 + 1] = g; row[x * 3 + 2] = g
}
file_write(f, row, sm * 3)
}
file_close(f)
}
free(buf); free(row)
}
var sh_printed: bool = false
# a uniform array's location: some drivers only answer to the "[0]" spelling
function sh_loc(prog: int, name: string) -> int {
var loc = gl_uniform(prog, name + "[0]")
if loc < 0 { loc = gl_uniform(prog, name) }
return loc
}
function shadow_bind(prog: int) -> void {
r3d_bind_tex(prog, "u_shadow", 15, GL_TEXTURE_2D_ARRAY, sh_tex)
# the height-field shadow (terrain.ludic); a stand-in texture keeps the unit valid before the bake
var ts = ter_shadow_tex
var ts_on = F_ONE
if ts == 0 { ts = ter_height_tex; ts_on = F_ZERO }
r3d_bind_2d(prog, "u_tershadow", 6, ts)
terrain_bind_height(prog)
u_f(gl_uniform(prog, "u_ts_on"), ts_on)
u_f(gl_uniform(prog, "u_ts_half"), fi(TERRAIN_HALF))
u_f2(gl_uniform(prog, "u_ts_origin"), ter_ox, ter_oz)
var loc = gl_uniform(prog, "u_cascade_vp[0]")
if loc < 0 { loc = gl_uniform(prog, "u_cascade_vp") }
if r3d_debug_shadow and not sh_printed { sh_printed = true; print(`cascade vp loc {loc} / {gl_uniform(prog, "u_cascade_vp")} split loc {gl_uniform(prog, "u_cascade_split")} shadow loc {gl_uniform(prog, "u_shadow")}`) }
gl_uniform_matrix4fv(loc, SHADOW_CASCADES, 0, sh_vp)
gl_uniform1fv(sh_loc(prog, "u_cascade_split"), SHADOW_CASCADES, sh_split)
if r3d_debug_shadow and not sh_printed3 { sh_printed3 = true; print(`range {f_fx(sh_range[0])} {f_fx(sh_range[1])} {f_fx(sh_range[2])} {f_fx(sh_range[3])} texel*1000 {f_fx(f_mul(sh_texel[0], fi(1000)))} {f_fx(f_mul(sh_texel[1], fi(1000)))} {f_fx(f_mul(sh_texel[2], fi(1000)))} {f_fx(f_mul(sh_texel[3], fi(1000)))} locs {gl_uniform(prog, "u_cascade_range")} {gl_uniform(prog, "u_cascade_texel")}`) }
gl_uniform1fv(sh_loc(prog, "u_cascade_range"), SHADOW_CASCADES, sh_range)
if Os.has_env("R3D_FORCE") { sh_force = Text.to_int(Os.env("R3D_FORCE")) }
gl_uniform1i(gl_uniform(prog, "u_force_cascade"), sh_force)
gl_uniform1fv(sh_loc(prog, "u_cascade_texel"), SHADOW_CASCADES, sh_texel)
}

View file

@ -0,0 +1,226 @@
# ============================================================================
# skin.ludic — skeletal skinning for glTF models. A Skin is the file's node
# hierarchy (rest translation / rotation / scale per node) plus the skin's joint
# list and inverse bind matrices. The game poses it by giving any node an extra
# rotation and offset IN THE MODEL'S FRAME (X right, Y up, -Z forward, whatever
# the bone's own axes happen to be), skin_pose() folds those into the hierarchy
# and produces the joint matrices, and skin.vert blends four of them per vertex.
#
# Posing in the model frame is what makes a procedural gait writable: "swing the
# thigh forward" is a rotation about the model's X axis, not about whichever axis
# the exporter gave the thigh bone. Per node the delta D is brought into the
# parent's rest frame G_p (the parent's global rest rotation): local rotation =
# (G_p^-1 D G_p) * R_rest.
# ============================================================================
const SKIN_MAX_JOINTS: int = 48
property Skin {
n_nodes: int = 0,
par: words, # parent node per node, -1 at a root
walk: words, # the nodes ordered parents-first
rest_t: words, # 3 per node
rest_r: words, # 4 per node (x, y, z, w)
rest_s: words, # 3 per node
rest_g: words, # 4 per node: the global rest rotation
names: []string,
pose_r: words, # 4 per node: the pose rotation, model frame
pose_t: words, # 3 per node: the pose offset, model frame (metres)
gmat: words, # 16 per node: global matrix this pose
n_joints: int = 0,
joints: words, # node index per joint
inv_bind: words, # 16 per joint
bones: words, # 16 per joint: what the vertex shader skins with
tmp_l: words,
tmp_q: words,
tmp_a: words,
tmp_b: words,
tmp_c: words,
tmp_v: words
}
# a 3-vector of a JSON array (float bits), or a default
function skin_jv3(o: words, at: int, nd: Val, key: pointer, dx: int, dy: int, dz: int) -> void {
if value_has(nd, key) == 0 { o[at] = dx; o[at + 1] = dy; o[at + 2] = dz; return }
let arr = value_get(nd, key)
for i in 0 .. 3 { o[at + i] = jnum(value_at(arr, i)) }
}
# JOINTS_0 / WEIGHTS_0 onto attributes 5 and 6 of the VAO being built (gltf_prim)
function skin_attribs(m: Mesh, attrs: Val) -> bool {
if value_has(attrs, "JOINTS_0") == 0 or value_has(attrs, "WEIGHTS_0") == 0 { return false }
let jd = gltf_accessor(value_as_int(value_get(attrs, "JOINTS_0")))
var jsz = 1
var jtype = GL_UNSIGNED_BYTE
if gltf_ctype == 5123 { jsz = 2; jtype = GL_UNSIGNED_SHORT }
let jb = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, jb)
gl_buffer_data(GL_ARRAY_BUFFER, gltf_count * gltf_comps * jsz, jd, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(5)
gl_vertex_attrib_pointer(5, gltf_comps, jtype, 0, 0, null) # integers, read as floats
free(jd)
let wd = gltf_accessor(value_as_int(value_get(attrs, "WEIGHTS_0")))
var wsz = 4
var wtype = GL_FLOAT
var norm = 0
if gltf_ctype == 5123 { wsz = 2; wtype = GL_UNSIGNED_SHORT; norm = 1 }
if gltf_ctype == 5121 { wsz = 1; wtype = GL_UNSIGNED_BYTE; norm = 1 }
let wb = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, wb)
gl_buffer_data(GL_ARRAY_BUFFER, gltf_count * gltf_comps * wsz, wd, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(6)
gl_vertex_attrib_pointer(6, gltf_comps, wtype, norm, 0, null)
free(wd)
return true
}
# the skin `idx` of the document being loaded (gltf_load holds gltf_doc / gltf_bin open)
function skin_load(idx: int) -> Skin {
let sk = new Skin
let nodes = value_get(gltf_doc, "nodes")
let n = value_count(nodes)
sk.n_nodes = n
sk.par = words(n); sk.walk = words(n)
sk.rest_t = words(n * 3); sk.rest_r = words(n * 4); sk.rest_s = words(n * 3); sk.rest_g = words(n * 4)
sk.pose_r = words(n * 4); sk.pose_t = words(n * 3); sk.gmat = words(n * 16)
sk.names = new []string
sk.tmp_l = m4_new(); sk.tmp_q = q_new(); sk.tmp_a = q_new(); sk.tmp_b = q_new(); sk.tmp_c = q_new(); sk.tmp_v = words(3)
for i in 0 .. n { sk.par[i] = -1 }
for i in 0 .. n {
let nd = value_at(nodes, i)
var nm: string = ""
if value_has(nd, "name") != 0 { nm = value_as_str(value_get(nd, "name")) }
push(sk.names, nm)
skin_jv3(sk.rest_t, i * 3, nd, "translation", F_ZERO, F_ZERO, F_ZERO)
skin_jv3(sk.rest_s, i * 3, nd, "scale", F_ONE, F_ONE, F_ONE)
if value_has(nd, "rotation") != 0 {
let r = value_get(nd, "rotation")
for k in 0 .. 4 { sk.rest_r[i * 4 + k] = jnum(value_at(r, k)) }
} else { sk.rest_r[i * 4] = F_ZERO; sk.rest_r[i * 4 + 1] = F_ZERO; sk.rest_r[i * 4 + 2] = F_ZERO; sk.rest_r[i * 4 + 3] = F_ONE }
if value_has(nd, "matrix") != 0 { print(`skin: node {nm} uses a matrix transform (unsupported, treated as identity)`) }
if value_has(nd, "children") != 0 {
let ch = value_get(nd, "children")
for k in 0 .. value_count(ch) { sk.par[value_as_int(value_at(ch, k))] = i }
}
}
# parents first: order the nodes by depth
let depth = words(n)
for i in 0 .. n {
var d = 0
var p = sk.par[i]
while p >= 0 and d < n { d += 1; p = sk.par[p] }
depth[i] = d
}
var k = 0
for d in 0 .. n { for i in 0 .. n { if depth[i] == d { sk.walk[k] = i; k += 1 } } }
free(depth)
# the global rest rotation of every node
for w in 0 .. n {
let i = sk.walk[w]
let p = sk.par[i]
q_load(sk.tmp_a, sk.rest_r, i)
if p >= 0 { q_load(sk.tmp_b, sk.rest_g, p); q_mul(sk.tmp_q, sk.tmp_b, sk.tmp_a); q_store(sk.rest_g, i, sk.tmp_q) }
else { q_store(sk.rest_g, i, sk.tmp_a) }
}
# the skin: joints and inverse bind matrices
let skv = value_at(value_get(gltf_doc, "skins"), idx)
let jl = value_get(skv, "joints")
var nj = value_count(jl)
if nj > SKIN_MAX_JOINTS { print(`skin: {nj} joints, only the first {SKIN_MAX_JOINTS} are used`); nj = SKIN_MAX_JOINTS }
sk.n_joints = nj
sk.joints = words(nj)
sk.inv_bind = words(nj * 16)
sk.bones = words(nj * 16)
for j in 0 .. nj { sk.joints[j] = value_as_int(value_at(jl, j)) }
if value_has(skv, "inverseBindMatrices") != 0 {
let ib = gltf_accessor(value_as_int(value_get(skv, "inverseBindMatrices")))
for i in 0 .. nj * 16 { sk.inv_bind[i] = mem_get_f32_bits(ib, i) }
free(ib)
} else {
for j in 0 .. nj { m4_identity(mem_off(sk.inv_bind, j * 64)) }
}
skin_reset(sk)
skin_pose(sk)
print(`skin: {nj} joints over {n} nodes`)
return sk
}
function skin_find(sk: Skin, name: string) -> int {
for i in 0 .. sk.n_nodes { if sk.names[i] == name { return i } }
print(`skin: no node {name}`)
return -1
}
function skin_mat(sk: Skin, node: int) -> words { return mem_off(sk.gmat, node * 64) }
# back to the rest pose
function skin_reset(sk: Skin) -> void {
for i in 0 .. sk.n_nodes {
sk.pose_r[i * 4] = F_ZERO; sk.pose_r[i * 4 + 1] = F_ZERO; sk.pose_r[i * 4 + 2] = F_ZERO; sk.pose_r[i * 4 + 3] = F_ONE
sk.pose_t[i * 3] = F_ZERO; sk.pose_t[i * 3 + 1] = F_ZERO; sk.pose_t[i * 3 + 2] = F_ZERO
}
}
# a node's pose rotation in the model frame: pitch about X, yaw about Y, roll about Z (radians)
function skin_set_rot(sk: Skin, node: int, pitch: int, yaw: int, roll: int) -> void {
if node < 0 { return }
q_euler(sk.tmp_q, pitch, yaw, roll)
q_store(sk.pose_r, node, sk.tmp_q)
}
function skin_set_quat(sk: Skin, node: int, q: words) -> void { if node >= 0 { q_store(sk.pose_r, node, q) } }
# a node's pose offset in the model frame (metres)
function skin_set_offset(sk: Skin, node: int, x: int, y: int, z: int) -> void {
if node < 0 { return }
sk.pose_t[node * 3] = x; sk.pose_t[node * 3 + 1] = y; sk.pose_t[node * 3 + 2] = z
}
# fold the pose into the hierarchy: global matrices, then the joint matrices
function skin_pose(sk: Skin) -> void {
for w in 0 .. sk.n_nodes {
let i = sk.walk[w]
let p = sk.par[i]
q_load(sk.tmp_a, sk.pose_r, i) # D, model frame
var tx = sk.rest_t[i * 3]; var ty = sk.rest_t[i * 3 + 1]; var tz = sk.rest_t[i * 3 + 2]
let ox = sk.pose_t[i * 3]; let oy = sk.pose_t[i * 3 + 1]; let oz = sk.pose_t[i * 3 + 2]
if p >= 0 {
q_load(sk.tmp_b, sk.rest_g, p) # G_p
q_conj(sk.tmp_c, sk.tmp_b) # G_p^-1
q_mul(sk.tmp_q, sk.tmp_c, sk.tmp_a)
q_mul(sk.tmp_a, sk.tmp_q, sk.tmp_b) # G_p^-1 D G_p
if ox != 0 or oy != 0 or oz != 0 {
v3_set(sk.tmp_v, ox, oy, oz)
q_rotate(sk.tmp_v, sk.tmp_c, sk.tmp_v) # the offset in the parent's frame
tx = f_add(tx, sk.tmp_v[0]); ty = f_add(ty, sk.tmp_v[1]); tz = f_add(tz, sk.tmp_v[2])
}
} else { tx = f_add(tx, ox); ty = f_add(ty, oy); tz = f_add(tz, oz) }
q_load(sk.tmp_b, sk.rest_r, i)
q_mul(sk.tmp_q, sk.tmp_a, sk.tmp_b) # local rotation
m4_trs_q(sk.tmp_l, tx, ty, tz, sk.tmp_q, sk.rest_s[i * 3], sk.rest_s[i * 3 + 1], sk.rest_s[i * 3 + 2])
if p >= 0 { m4_mul(skin_mat(sk, i), skin_mat(sk, p), sk.tmp_l) }
else { m4_copy(skin_mat(sk, i), sk.tmp_l) }
}
for j in 0 .. sk.n_joints {
m4_mul(mem_off(sk.bones, j * 64), skin_mat(sk, sk.joints[j]), mem_off(sk.inv_bind, j * 64))
}
}
# the joint matrices onto a program's u_bones[]
function skin_bind(sk: Skin, prog: int) -> void {
var loc = gl_uniform(prog, "u_bones[0]")
if loc < 0 { loc = gl_uniform(prog, "u_bones") }
gl_uniform_matrix4fv(loc, sk.n_joints, 0, sk.bones)
}
# the same skeleton posed on its own: shares the rest data, owns the pose and the matrices
function skin_clone(src: Skin) -> Skin {
let sk = new Skin
sk.n_nodes = src.n_nodes; sk.par = src.par; sk.walk = src.walk
sk.rest_t = src.rest_t; sk.rest_r = src.rest_r; sk.rest_s = src.rest_s; sk.rest_g = src.rest_g
sk.names = src.names
sk.n_joints = src.n_joints; sk.joints = src.joints; sk.inv_bind = src.inv_bind
let n = src.n_nodes
sk.pose_r = words(n * 4); sk.pose_t = words(n * 3); sk.gmat = words(n * 16)
sk.bones = words(src.n_joints * 16)
sk.tmp_l = m4_new(); sk.tmp_q = q_new(); sk.tmp_a = q_new(); sk.tmp_b = q_new(); sk.tmp_c = q_new(); sk.tmp_v = words(3)
skin_reset(sk)
skin_pose(sk)
return sk
}

View file

@ -0,0 +1,134 @@
# ============================================================================
# sky.ludic — the HDRI sky and its image-based lighting: the equirect radiance
# map (RGB16F, mipped), the sun found in it, a diffuse-convolved irradiance map,
# a GGX-prefiltered map per roughness level (a 2D array), and the split-sum
# BRDF lookup. All convolved on the GPU at load.
# ============================================================================
const SKY_PREFILTER_LEVELS: int = 6
var sky_tex: int = 0
var sky_w: int = 0
var sky_h: int = 0
var sky_irradiance: int = 0
var sky_prefilter: int = 0 # GL_TEXTURE_2D_ARRAY
var sky_brdf: int = 0
var sun_dir: words = null # toward the sun (float bits)
var sun_color: words = null # radiance (float bits)
var sky_fullscreen: Mesh = null
var sky_yaw: int = 0 # radians: the HDRI is turned by this about y
var sky_sun_boost: int = 0x40133333 # 2.3: the photograph's thin cloud dims its sun; a crisper day wants more
var sky_rot_s: int = 0
var sky_rot_c: int = 0
var sun_hdri: words = null # the sun direction as found in the file
var sky_p_irr: int = 0
var sky_p_pre: int = 0
var sky_p_brdf: int = 0
# turn the HDRI so its sun sits at world azimuth `yaw` (radians, 0 = toward -z)
function sky_set_yaw(yaw: int) -> void {
sky_set_rot(yaw)
# world sun = rotY(sun_hdri, -yaw): the lookup rotates a world direction by +yaw
let s = sun_hdri
v3_set(sun_dir, f_sub(f_mul(sky_rot_c, s[0]), f_mul(sky_rot_s, s[2])), s[1], f_add(f_mul(sky_rot_s, s[0]), f_mul(sky_rot_c, s[2])))
sky_precompute()
}
# turn only the visible sky image (cheap, per frame): the light and the convolved
# maps stay where they are — daylight.ludic moves those on its own terms
function sky_set_rot(yaw: int) -> void {
sky_yaw = yaw
sky_rot_s = f_sin(yaw); sky_rot_c = f_cos(yaw)
}
function sky_bind_rot(prog: int) -> void { u_f2(gl_uniform(prog, "u_sky_rot"), sky_rot_s, sky_rot_c) }
# direction for an equirect uv (matches equirectUV in lighting.glsl)
function sky_dir_from_uv(o: words, u: int, v: int) -> void {
let phi = f_mul(f_sub(u, F_HALF), f_mul(F_TWO, F_PI))
let theta = f_mul(v, F_PI)
let st = f_sin(theta)
v3_set(o, f_mul(st, f_sin(phi)), f_cos(theta), f_neg(f_mul(st, f_cos(phi))))
}
function sky_load(path: string) -> bool {
sky_tex = tex_load_hdr(path)
if sky_tex == 0 { return false }
sky_w = tex_w; sky_h = tex_h
sun_dir = words(3)
sun_hdri = words(3)
sky_dir_from_uv(sun_hdri, fr(hdr_max_x * 2 + 1, sky_w * 2), fr(hdr_max_y * 2 + 1, sky_h * 2))
v3_copy(sun_dir, sun_hdri)
sky_rot_c = F_ONE
# the sun's irradiance is what the IBL clip leaves out of the map; lighting it
# directly with that keeps sun and sky in the photograph's own proportion
sun_color = v3_new(f_mul(hdr_sun_r, sky_sun_boost), f_mul(hdr_sun_g, sky_sun_boost), f_mul(hdr_sun_b, sky_sun_boost))
print(`sun irradiance: {f_fx(hdr_sun_r)} {f_fx(hdr_sun_g)} {f_fx(hdr_sun_b)} (Q16.16), clip {f_fx(hdr_clip)}`)
print(`sky: {sky_w}x{sky_h}, sun at texel {hdr_max_x},{hdr_max_y}`)
sky_fullscreen = mesh_fullscreen()
sky_precompute()
return true
}
function sky_convolve(prog: int, target_tex: int, layer: int, w: int, h: int, rough: int) -> void {
let fbo = gl_framebuffer()
gl_bind_framebuffer(GL_FRAMEBUFFER, fbo)
if layer < 0 { gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target_tex, 0) }
else { gl_framebuffer_texture_layer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, target_tex, 0, layer) }
gl_viewport(0, 0, w, h)
gl_use_program(prog)
r3d_bind_2d(prog, "u_sky", 0, sky_tex)
sky_bind_rot(prog)
u_f(gl_uniform(prog, "u_sun_clip"), hdr_clip)
u_f(gl_uniform(prog, "u_rough"), rough)
u_f(gl_uniform(prog, "u_sky_w"), fi(sky_w))
mesh_draw(sky_fullscreen)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
let ids = gl_scratch()
ids[0] = fbo
gl_delete_framebuffers(1, ids)
}
function sky_precompute() -> void {
gl_disable(GL_DEPTH_TEST)
if sky_irradiance != 0 {
let ids = gl_scratch()
ids[0] = sky_irradiance; gl_delete_textures(1, ids)
ids[0] = sky_prefilter; gl_delete_textures(1, ids)
ids[0] = sky_brdf; gl_delete_textures(1, ids)
}
# irradiance: 128 x 64 equirect
if sky_p_irr == 0 { sky_p_irr = r3d_program("fullscreen.vert", "ibl_irradiance.frag", ""); sky_p_pre = r3d_program("fullscreen.vert", "ibl_prefilter.frag", ""); sky_p_brdf = r3d_program("fullscreen.vert", "ibl_brdf.frag", "") }
let p_irr = sky_p_irr
sky_irradiance = tex_target(128, 64, GL_RGB16F, GL_RGB, GL_FLOAT, GL_LINEAR)
gl_bind_texture(GL_TEXTURE_2D, sky_irradiance)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
sky_convolve(p_irr, sky_irradiance, -1, 128, 64, F_ZERO)
# prefiltered specular: 6 roughness levels, 512 x 256 each, as a 2D array
let p_pre = sky_p_pre
sky_prefilter = gl_texture()
gl_bind_texture(GL_TEXTURE_2D_ARRAY, sky_prefilter)
gl_tex_image3d(GL_TEXTURE_2D_ARRAY, 0, GL_RGB16F, 512, 256, SKY_PREFILTER_LEVELS, 0, GL_RGB, GL_FLOAT, null)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
gl_tex_parameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
for l in 0 .. SKY_PREFILTER_LEVELS {
sky_convolve(p_pre, sky_prefilter, l, 512, 256, fr(l, SKY_PREFILTER_LEVELS - 1))
}
# BRDF LUT
let p_brdf = sky_p_brdf
sky_brdf = tex_target(256, 256, GL_RG16F, GL_RG, GL_FLOAT, GL_LINEAR)
sky_convolve(p_brdf, sky_brdf, -1, 256, 256, F_ZERO)
gl_check("sky precompute")
}
# bind the IBL set + sun for a lit program (units 12..14)
function sky_bind_lighting(prog: int) -> void {
r3d_bind_2d(prog, "u_irradiance", 12, sky_irradiance)
r3d_bind_tex(prog, "u_prefilter", 13, GL_TEXTURE_2D_ARRAY, sky_prefilter)
r3d_bind_2d(prog, "u_brdf", 14, sky_brdf)
u_v3(gl_uniform(prog, "u_sun_dir"), sun_dir)
u_v3(gl_uniform(prog, "u_sun_color"), sun_color)
u_v3(gl_uniform(prog, "u_cam_pos"), cam_pos)
u_f(gl_uniform(prog, "u_prefilter_levels"), fi(SKY_PREFILTER_LEVELS))
daylight_bind(prog)
}

View file

@ -0,0 +1,315 @@
# ============================================================================
# stream.ludic — ground cover that follows the camera anywhere on the map.
#
# The world is cut into square chunks. A chunk's instances are generated once
# per distance band (a deterministic function of the chunk and the band, so the
# same ground always grows the same grass) and cached; each frame the chunks
# within reach are gathered into the layer's instance list. Bands thin the cover
# with distance and grow the cards so the carpet stays continuous on screen;
# beyond the last band nothing is placed (the terrain material carries it).
#
# The scene supplies the generator: stream_fill(chunk_x, chunk_z, band) calls
# stream_emit(...) per instance. One Stream drives one Layer.
# ============================================================================
var STREAM_MAX_CHUNKS: int = 4096
property Chunk {
key: int = 0, # packed (cx, cz, band)
used: int = 0, # the walk that last wanted it (for eviction)
data: words, # INST_FLOATS per instance
count: int = 0,
ymin: int = 0, # height range of its instances (float bits), for the frustum test
ymax: int = 0
}
property Stream {
layer: Layer,
size: int = 0, # chunk size (metres, float bits)
reach: int = 0, # radius (metres, float bits)
bands: words, # band outer radii (float bits), ascending; 4 of them
chunks: []Chunk,
keys: words, # parallel to chunks for lookup
n: int = 0,
last_cx: int = 999999,
last_cz: int = 999999,
pending: bool = false, # chunks still to generate after the camera crossed a cell
cur: Chunk, # the chunk being filled
kind: int = 0, # the scene's generator selector for this stream
min_band: int = 0, # bands below this belong to another (nearer) stream
view_gen: int = -1, # sc_view_gen the layer was last gathered for (the view turned -> regather)
htab: words # open-addressed key -> chunk index + 1 (0 = empty)
}
var stream_all: []Stream = null
var stream_cap_read: bool = false
var stream_no_evict: bool = false # R3D_NOEVICT: the old behaviour, for comparison
var stream_walk_no: int = 0 # counts ring walks; a chunk's age is measured in these
var stream_evictions: int = 0
# microseconds spent per frame, split so the hitch can be attributed (R3D_PROF=1)
var stream_us_gen: long = 0 # generating new chunks (stream_fill)
var stream_us_gather: long = 0 # copying cached chunks into the layer buffer
var stream_us_walk: long = 0 # the ring walk itself
var stream_walks: int = 0 # streams that walked their whole ring this frame
function stream_new(layer: Layer, size: int, reach: int, b0: int, b1: int, b2: int, b3: int) -> Stream {
if not stream_cap_read {
stream_cap_read = true
if Os.has_env("R3D_STREAM_CAP") { STREAM_MAX_CHUNKS = Text.to_int(Os.env("R3D_STREAM_CAP")) }
stream_no_evict = Os.has_env("R3D_NOEVICT")
}
let s = new Stream
s.layer = layer; s.size = size; s.reach = reach
layer.streamed = true
layer.grounded = true
s.bands = words(4)
s.bands[0] = b0; s.bands[1] = b1; s.bands[2] = b2; s.bands[3] = b3
s.chunks = new []Chunk
s.keys = words(STREAM_MAX_CHUNKS)
s.htab = words(STREAM_HASH)
for i in 0 .. STREAM_HASH { s.htab[i] = 0 }
if stream_all == null { stream_all = new []Stream }
push(stream_all, s)
return s
}
function stream_key(cx: int, cz: int, band: int) -> int { return ((cx + 4096) * 8192 + (cz + 4096)) * 4 + band }
# Chunk lookup is an open-addressed hash, not a scan. A cell crossing tests every cell
# within reach — for the 800 m streams that is ~2000 cells each — and a scan over the
# cached chunks made that O(cells x chunks), tens of millions of comparisons in the one
# frame that crosses a 32 m boundary, growing as more ground is explored. That is the
# stutter you feel when walking, and it never shows in a stationary profile because
# stream_update returns immediately while the camera stays in its cell.
const STREAM_HASH: int = 8192 # power of two, >= 2 * STREAM_MAX_CHUNKS
function stream_slot(key: int) -> int {
var h = key * -1640531527 # Knuth's golden-ratio multiplier, as a signed i32
h = h ^ (h >> 15)
return h & (STREAM_HASH - 1)
}
function stream_find(s: Stream, key: int) -> Chunk {
var i = stream_slot(key)
while s.htab[i] != 0 {
let idx = s.htab[i] - 1
if s.keys[idx] == key { return s.chunks[idx] }
i = (i + 1) & (STREAM_HASH - 1)
}
return null
}
function stream_remember(s: Stream, key: int, idx: int) -> void {
var i = stream_slot(key)
while s.htab[i] != 0 { i = (i + 1) & (STREAM_HASH - 1) }
s.htab[i] = idx + 1
}
# the generator adds instances to the chunk being filled (into a shared scratch; the
# chunk gets an exactly-sized copy when the fill ends)
const STREAM_CHUNK_MAX: int = 262144
var stream_debug_n: int = 0
var stream_scratch: words = null
function stream_emit(s: Stream, x: int, y: int, z: int, scale: int, yaw: int, seed: int, wind: int) -> void {
let c = s.cur
if c.count >= STREAM_CHUNK_MAX { return }
if stream_scratch == null { stream_scratch = words(STREAM_CHUNK_MAX * INST_FLOATS) }
if c.count == 0 { c.ymin = y; c.ymax = y } else { c.ymin = f_min(c.ymin, y); c.ymax = f_max(c.ymax, y) }
let o = c.count * INST_FLOATS
stream_scratch[o] = x; stream_scratch[o + 1] = y; stream_scratch[o + 2] = z; stream_scratch[o + 3] = scale
stream_scratch[o + 4] = f_sin(yaw); stream_scratch[o + 5] = f_cos(yaw); stream_scratch[o + 6] = seed; stream_scratch[o + 7] = wind
c.count += 1
}
function stream_band(s: Stream, d: int) -> int {
if f_ls(d, s.bands[0]) { return 0 }
if f_ls(d, s.bands[1]) { return 1 }
if f_ls(d, s.bands[2]) { return 2 }
if f_ls(d, s.bands[3]) { return 3 }
return 4
}
# gather the chunks around the camera into the layer, generating missing ones nearest
# first within a per-frame budget so a cell crossing spreads over a few frames instead of
# one hitch (the very first update, before anything is on screen, generates everything)
# The budget is global, not per stream. It used to be 60000 per stream, and with the
# thirteen streams a scene like the valley runs that let a single frame generate over
# 700k instances — so crossing a 32 m cell put one frame's worth of cover generation
# (height samples, ortho lookups, slope and path tests, per candidate) into one frame
# while its neighbours did none. That one frame is the stutter you feel while walking;
# spreading the same work over several frames costs nothing but a little pop-in at the
# far edge of the reach, where new chunks appear.
# A time budget, not an instance count. Instances are a poor proxy: a candidate that
# is rejected costs nearly as much as one that is kept, and cost per instance varies
# by band and kind. With a real microsecond clock the budget can just be the thing we
# actually care about — how long this frame is allowed to spend growing ground cover.
# Overshoot is bounded by one chunk, so keep chunks small on the dense near streams.
const STREAM_BUDGET_US: int = 2500 # microseconds of generation per frame
var stream_deadline: long = 0
const STREAM_BUDGET: int = 8000 # kept for the work counter only
# The worst frame is now bounded by one chunk, not by the budget: stream_fill emits a
# whole chunk in one call, and the densest band-0 chunk is ~114k instances. Splitting a
# chunk's generation across frames would need a resumable generator contract; that is
# the next step if the residual hitch ever matters.
var stream_budget_left: int = 0
# Drop the half of the cache nobody has asked for in the longest time, and rebuild the
# index over what is left.
#
# Before this, a full cache simply stopped remembering: the chunk was generated, used for
# that frame and thrown away, so every walk regenerated it. That is not a slow degradation
# — it is a cliff. Past it every frame pays the whole generation budget and the ground
# visibly re-grows as you turn, and it arrives after enough of the map has been walked,
# which is exactly when a player is least likely to connect it to anything.
function stream_evict(s: Stream) -> void {
# the age threshold that keeps about half, found by bisection on the count (no sort)
var lo = 0
var hi = stream_walk_no
var keep = s.n / 2
var t = 0
var it = 0
while it < 24 and lo < hi {
t = (lo + hi + 1) / 2
var c = 0
var i = 0
while i < s.n { if s.chunks[i].used >= t { c += 1 }; i += 1 }
if c >= keep { lo = t } else { hi = t - 1 }
it += 1
}
t = lo
# everything wanted by the walk in progress stays whatever the threshold says
let kept = new []Chunk
var i = 0
while i < s.n {
let c = s.chunks[i]
if c.used >= t or c.used == stream_walk_no { push(kept, c) }
else { if c.data != null { free(c.data) } }
i += 1
}
s.chunks = kept
s.n = len(kept)
for h in 0 .. STREAM_HASH { s.htab[h] = 0 }
i = 0
while i < s.n { s.keys[i] = s.chunks[i].key; stream_remember(s, s.chunks[i].key, i); i += 1 }
stream_evictions += 1
}
function stream_update(s: Stream, cam_x: int, cam_z: int) -> void {
let ccx = f_to_int(f_floor(f_div(cam_x, s.size)))
let ccz = f_to_int(f_floor(f_div(cam_z, s.size)))
if ccx == s.last_cx and ccz == s.last_cz and not s.pending and s.view_gen == sc_view_gen { return }
let first = s.last_cx == 999999
s.view_gen = sc_view_gen
s.last_cx = ccx; s.last_cz = ccz
let l = s.layer
l.count = 0
var missing = false
stream_walks += 1
stream_walk_no += 1
let tw = gl_now_us()
let r = f_to_int(f_div(s.reach, s.size)) + 1
# rings outward from the camera's cell: the nearest chunks are generated first
var ring = 0
while ring <= r {
var cz = ccz - ring
while cz <= ccz + ring {
var cx = ccx - ring
while cx <= ccx + ring {
let edge = (cz == ccz - ring) or (cz == ccz + ring) or (cx == ccx - ring) or (cx == ccx + ring)
if edge {
let wx = f_mul(f_add(fi(cx), F_HALF), s.size)
let wz = f_mul(f_add(fi(cz), F_HALF), s.size)
let dx = f_sub(wx, cam_x); let dz = f_sub(wz, cam_z)
let d = f_sqrt(f_add(f_mul(dx, dx), f_mul(dz, dz)))
let band = stream_band(s, d)
if band < 4 and band >= s.min_band and f_ls(d, f_add(s.reach, s.size)) {
let key = stream_key(cx, cz, band)
var c = stream_find(s, key)
if c != null { c.used = stream_walk_no }
# The cell underfoot and its neighbours are never deferred: they are what you
# are looking at, and a hole there is the grass vanishing as you walk into it.
let urgent = band == 0 and ring <= 1
if c == null and (first or urgent or gl_now_us() < stream_deadline) {
c = new Chunk
c.key = key
s.cur = c
let t0 = gl_now_us()
stream_fill(s, cx, cz, band)
let dt = gl_now_us() - t0
stream_us_gen = stream_us_gen + dt
prof_chunk(s.kind, band, c.count, dt)
if r3d_debug and band == 0 and stream_debug_n < 40 { stream_debug_n += 1; print(`stream kind {s.kind} band {band} chunk {cx},{cz}: {c.count} instances`) }
if c.count > 0 { c.data = words(c.count * INST_FLOATS); mem_copy(c.data, stream_scratch, c.count * INST_FLOATS * 4) }
if s.n >= STREAM_MAX_CHUNKS and not stream_no_evict { stream_evict(s) }
# If the walk in progress wants more chunks than the cache can hold, there
# is nothing to evict and this one is used and dropped, as every chunk used
# to be. The cap has to exceed one walk's ring for the cache to work at all.
if s.n < STREAM_MAX_CHUNKS {
push(s.chunks, c); s.keys[s.n] = key; stream_remember(s, key, s.n); s.n += 1
c.used = stream_walk_no
}
prof_gen_add(c.count + 512)
}
if c == null {
missing = true
# Until the finer band is generated, show the coarser one this ground had
# a moment ago (same cell, next band out): approaching grass thins for a
# few frames instead of disappearing.
var b2 = band + 1
while c == null and b2 < 4 { c = stream_find(s, stream_key(cx, cz, b2)); b2 += 1 }
if c != null { c.used = stream_walk_no }
}
if c != null and c.count > 0 and l.count + c.count <= l.cap and stream_chunk_visible(s, cx, cz, c) {
let tg = gl_now_us()
mem_copy(mem_off(l.inst, l.count * INST_FLOATS * 4), c.data, c.count * INST_FLOATS * 4)
l.count += c.count
stream_us_gather = stream_us_gather + (gl_now_us() - tg)
}
}
}
cx += 1
}
cz += 1
}
ring += 1
}
s.pending = missing
stream_us_walk = stream_us_walk + (gl_now_us() - tw)
# force the layer to re-partition its (new) instances
l.view_gen = -1
}
# Only chunks that can be seen are gathered: a sphere around the chunk's footprint and
# height range, padded for the tallest cover and for casters just outside the frame
# whose short shadows still fall inside it.
function stream_chunk_visible(s: Stream, cx: int, cz: int, c: Chunk) -> bool {
let half = f_mul(s.size, F_HALF)
let wx = f_add(f_mul(fi(cx), s.size), half)
let wz = f_add(f_mul(fi(cz), s.size), half)
let hy = f_mul(f_sub(c.ymax, c.ymin), F_HALF)
let cy = f_add(c.ymin, hy)
let r = f_add(f_sqrt(f_add(f_mul(f_mul(half, half), F_TWO), f_mul(hy, hy))), fi(8))
return cam_sphere_visible(wx, cy, wz, r)
}
# what the caches hold, and whether they are being churned (R3D_PROF)
function stream_census() -> void {
if stream_all == null { return }
print("")
print(`ground-cover chunk caches (cap {string(STREAM_MAX_CHUNKS)} each, {string(stream_evictions)} evictions over the run):`)
var inst = 0
for i in 0 .. len(stream_all) {
let s = stream_all[i]
var n = 0
for k in 0 .. s.n { n += s.chunks[k].count }
inst += n
print(` stream kind {string(s.kind)}: {string(s.n)} chunks, {string(n)} instances`)
}
print(` {string(inst)} instances held, {string(inst * INST_FLOATS * 4 / 1024)} KB`)
}
function stream_update_all() -> void {
if stream_all == null { return }
stream_deadline = gl_now_us() + STREAM_BUDGET_US
for i in 0 .. len(stream_all) { stream_update(stream_all[i], cam_pos[0], cam_pos[2]) }
}

View file

@ -0,0 +1,706 @@
# ============================================================================
# terrain.ludic — the landscape: a height map generated on the GPU (R32F),
# read back for placement queries, drawn as a lifted grid with four scanned
# PBR materials blended by slope, altitude and the track mask.
# ============================================================================
var TERRAIN_HALF: int = 4096 # world half-size in metres (8 km square)
const TERRAIN_RES: int = 4096 # height-map texels per side (2 m over 8 km)
const TERRAIN_SHADOW_RES: int = 2048 # the baked height-field shadow / cloud mask
# CDLOD: the map is a quadtree of 32x32-cell patches; the leaf patch is 32 m (1 m cells)
const CD_G: int = 32 # cells per patch side
const CD_LEVELS: int = 9 # 32 m leaves .. 8192 m root
const CD_LEAVES: int = 256 # leaf patches per side (8192 / 32)
var cd_mesh: Mesh = null
var cd_range: words = null # float bits: how far each level is drawn
var cd_min: []words = null # per level: min height of each patch (float bits)
var cd_max: []words = null
var cd_draws: int = 0
var cd_far_draws: int = 0
var cd_near_draws: int = 0
var ter_force_far: bool = false
var ter_no_split: bool = false
var ter_force_near: bool = false
var ter_skip: bool = false
var ter_height_tex: int = 0
var ter_heights: words = null # CPU copy, float bits, TERRAIN_RES^2
var ter_reflect: bool = false # drawing the reflection: the mid mesh is plenty
var ter_prog: int = 0
# The far tier compiled on its own (FAR_ONLY). A patch that lies entirely beyond the
# near/far split is drawn with it: same pixels, a shader small enough to run wide.
var ter_prog_far: int = 0
var ter_prog_near: int = 0 # the detailed tier alone (NEAR_ONLY)
var ter_sun_prog: int = 0 # tersun.frag: sun visibility into a screen buffer
var ter_sun_tgt: Target = null # that buffer, at the frame's size
var ter_sun_refl: Target = null # and at the reflection's, which is smaller
var ter_sun_dumped: bool = false
var ter_sun_checked: bool = false
var ter_sun_done: bool = false # the caller already ran the pass (so it can time it)
var ter_sun_pass: bool = false # selection is drawing the visibility pass
var ter_sun_tex: int = 0
var ter_prog_cur: int = 0 # the program currently bound during selection
var ter_smooth: bool = false # generate the analytic test ground instead of a survey
var ter_far_split: int = 0 # metres: beyond this the terrain takes its cheap far path (R3D_TFAR)
var ter_far_band: int = 0 # half-width of the near/far blend (R3D_TBAND)
var ter_snow_line: int = 0
var ter_tex: words = null # 11 material textures (see terrain_bind)
var ter_ox: int = 0 # world x/z of the terrain centre (float bits)
var ter_oz: int = 0
var ter_dem_tex: int = 0 # a real height map (16-bit), or 0 for the procedural valley
var ter_dem_blur: int = 0 # gaussian texels applied to the survey (0 for lidar; ~3 for 30 m data)
var ter_dem_min: int = 0
var ter_dem_max: int = 0
var ter_dem_base: int = 0
var ter_ortho_tex: int = 0 # a photograph of the same window, draped with distance
var ter_carpet: int = 0 # the distant-grass carpet (carpet_bake), 0 = none
var ter_shadow_tex: int = 0 # height-field sun shadow: RG32F (lowest lit height, occluder distance)
var ter_shadow_yaw: int = 0x7fffffff # the sky yaw it was baked for
var ter_shadow_gen: int = -1 # the daylight generation it was baked for
var ter_shadow_prog: int = 0
function terrain_set_carpet(tex: int) -> void { ter_carpet = tex }
var ter_lake_level: int = 0 # a lake carved into the height map (float bits; ex = 0 → none)
var ter_lake_cx: int = 0
var ter_lake_cz: int = 0
var ter_lake_ex: int = 0
var ter_lake_ez: int = 0
# Carve a lake bed below `level` inside the ellipse (cx, cz) ± (ex, ez); call before r3d_init.
function terrain_lake(level: int, cx: int, cz: int, ex: int, ez: int) -> void {
ter_lake_level = level; ter_lake_cx = cx; ter_lake_cz = cz; ter_lake_ex = ex; ter_lake_ez = ez
}
# Use a real place: a 16-bit PNG height map plus its elevation range (metres). The
# elevation `base` becomes y = 0; `ox`/`oz` put the map's centre in the world.
var ter_ortho_px: pointer = null # the photograph on the CPU (RGB8, TERRAIN_RES^2) for placement rules
var ter_ortho_w: int = 0
var ter_ortho_c: int = 3
# the photograph's colour at world (x, z): packed 0xRRGGBB (0 outside the map)
function terrain_ortho(x: int, z: int) -> int {
if ter_ortho_px == null { return 0 }
if ter_o_scale == 0 { ter_o_scale = fr(ter_ortho_w, TERRAIN_HALF * 2) }
let scale = ter_o_scale
var ix = f_to_int(f_floor(f_mul(f_add(f_sub(x, ter_ox), fi(TERRAIN_HALF)), scale)))
var iz = f_to_int(f_floor(f_mul(f_add(f_sub(z, ter_oz), fi(TERRAIN_HALF)), scale)))
if ix < 0 { ix = 0 }; if iz < 0 { iz = 0 }
if ix > ter_ortho_w - 1 { ix = ter_ortho_w - 1 }; if iz > ter_ortho_w - 1 { iz = ter_ortho_w - 1 }
let o = (iz * ter_ortho_w + ix) * ter_ortho_c
return (ter_ortho_px[o] << 16) | (ter_ortho_px[o + 1] << 8) | ter_ortho_px[o + 2]
}
# The three classifiers below all read the same pixel. A caller that wants more than
# one should fetch the colour once with terrain_ortho() and use the *_of forms — the
# cover generator tests all three per candidate, so this is three fetches saved out of
# every four in the hottest loop in the program.
function ortho_green_of(c: int) -> int {
let r = (c >> 16) & 255; let g = (c >> 8) & 255; let b = c & 255
var v = g - max(r, b)
if v < 0 { v = 0 }
return f_min(fr(v, 22), F_ONE)
}
function ortho_scree_of(c: int) -> int {
if c == 0 { return F_ZERO }
let r = (c >> 16) & 255; let g = (c >> 8) & 255; let b = c & 255
let mx = max(r, max(g, b))
if g - max(r, b) > 2 or mx < 60 { return F_ZERO }
return F_ONE
}
function ortho_forest_of(c: int) -> int {
if c == 0 { return F_ZERO }
let r = (c >> 16) & 255; let g = (c >> 8) & 255; let b = c & 255
let mx = max(r, max(g, b))
if g - max(r, b) < 3 { return F_ZERO }
if mx <= 80 { return F_ONE }
if mx <= 105 { return F_HALF }
return F_ZERO
}
# how green the ground is in the photograph (0..1 float bits): meadow / forest vs rock, scree, water
function terrain_ortho_green(x: int, z: int) -> int {
let c = terrain_ortho(x, z)
let r = (c >> 16) & 255; let g = (c >> 8) & 255; let b = c & 255
var v = g - max(r, b) # green excess
if v < 0 { v = 0 }
return f_min(fr(v, 22), F_ONE)
}
# how grey and mid-bright (scree / pebbles / bare rock) the photograph is there (0..1)
# Bare ground only where most of a 50 m neighbourhood is bare: a single 10 m trail pixel
# must not place a boulder or bar a tree.
function terrain_ortho_scree(x: int, z: int) -> int {
var votes = 0
for j in 0 .. 5 { for i in 0 .. 5 { if ortho_scree_of(terrain_ortho(f_add(x, fi((i - 2) * 10)), f_add(z, fi((j - 2) * 10)))) != F_ZERO { votes += 1 } } }
if votes >= 15 { return F_ONE }
return F_ZERO
}
function terrain_ortho_scree_pixel(x: int, z: int) -> int {
let c = terrain_ortho(x, z)
if c == 0 { return F_ZERO }
let r = (c >> 16) & 255; let g = (c >> 8) & 255; let b = c & 255
let mx = max(r, max(g, b))
# bare ground: not green-dominant (grey scree, the maroon rock, the moraine's tan gravel), lit enough not to be water
if g - max(r, b) > 2 or mx < 60 { return F_ZERO }
return F_ONE
}
# dense conifer forest in the photograph: green-dominant and dark (the meadows are brighter)
function terrain_ortho_forest(x: int, z: int) -> int {
let c = terrain_ortho(x, z)
if c == 0 { return F_ZERO }
let r = (c >> 16) & 255; let g = (c >> 8) & 255; let b = c & 255
let mx = max(r, max(g, b))
if g - max(r, b) < 3 { return F_ZERO }
if mx <= 80 { return F_ONE }
if mx <= 105 { return F_HALF }
return F_ZERO
}
function terrain_use_ortho(path: string) -> void {
let px = png_decode(path)
if px == null { return }
ter_ortho_px = px
ter_ortho_w = tex_w
ter_ortho_c = tex_channels
ter_ortho_tex = tex_upload(px, true, true)
gl_bind_texture(GL_TEXTURE_2D, ter_ortho_tex)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
}
function terrain_use_dem(path: string, emin: int, emax: int, base: int, ox: int, oz: int) -> void {
ter_dem_tex = tex_load(path, false)
ter_dem_min = emin; ter_dem_max = emax; ter_dem_base = base
ter_ox = ox; ter_oz = oz
gl_bind_texture(GL_TEXTURE_2D, ter_dem_tex)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
}
function terrain_generate() -> void {
var ids0: words = null
var defs = ""
if ter_dem_tex != 0 { defs = "#define DEM\n" }
if ter_smooth { defs = "#define SMOOTH\n" }
let p = r3d_program("fullscreen.vert", "heightgen.frag", defs)
ter_height_tex = tex_target(TERRAIN_RES, TERRAIN_RES, GL_R32F, GL_RED, GL_FLOAT, GL_LINEAR)
let fbo = gl_framebuffer()
gl_bind_framebuffer(GL_FRAMEBUFFER, fbo)
gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ter_height_tex, 0)
gl_viewport(0, 0, TERRAIN_RES, TERRAIN_RES)
gl_disable(GL_DEPTH_TEST)
gl_use_program(p)
u_f(gl_uniform(p, "u_half"), fi(TERRAIN_HALF))
if ter_dem_tex != 0 {
r3d_bind_2d(p, "u_dem", 0, ter_dem_tex)
u_f(gl_uniform(p, "u_dem_min"), ter_dem_min)
u_f(gl_uniform(p, "u_dem_max"), ter_dem_max)
u_f(gl_uniform(p, "u_dem_base"), ter_dem_base)
u_f2(gl_uniform(p, "u_origin"), ter_ox, ter_oz)
u_f4(gl_uniform(p, "u_lake"), ter_lake_cx, ter_lake_cz, ter_lake_ex, ter_lake_ez)
u_f(gl_uniform(p, "u_lake_level"), ter_lake_level)
u_f(gl_uniform(p, "u_dem_blur"), ter_dem_blur)
}
mesh_draw(sky_fullscreen)
# second pass: R = height, GBA = the smooth surface normal, baked once (ternormal.frag)
let raw = ter_height_tex
ter_height_tex = tex_target(TERRAIN_RES, TERRAIN_RES, GL_RGBA32F, GL_RGBA, GL_FLOAT, GL_LINEAR)
gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ter_height_tex, 0)
let pn = r3d_program("fullscreen.vert", "ternormal.frag", "")
gl_use_program(pn)
r3d_bind_2d(pn, "u_src", 0, raw)
u_f(gl_uniform(pn, "u_half"), fi(TERRAIN_HALF))
mesh_draw(sky_fullscreen)
gl_delete_program(pn)
ids0 = gl_scratch(); ids0[0] = raw; gl_delete_textures(1, ids0)
# read the heights back for placement
ter_heights = words(TERRAIN_RES * TERRAIN_RES)
gl_bind_texture(GL_TEXTURE_2D, ter_height_tex)
gl_pixel_storei(GL_PACK_ALIGNMENT, 4)
gl_get_tex_image(GL_TEXTURE_2D, 0, GL_RED, GL_FLOAT, ter_heights)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
let ids = gl_scratch()
ids[0] = fbo
gl_delete_framebuffers(1, ids)
gl_delete_program(p)
gl_check("terrain generate")
}
# The height field for shaders that place things on the ground (model.vert's u_ground)
function terrain_bind_height(p: int) -> void {
r3d_bind_2d(p, "u_ts_height", 5, ter_height_tex)
u_f(gl_uniform(p, "u_ts_half"), fi(TERRAIN_HALF))
u_f2(gl_uniform(p, "u_ts_origin"), ter_ox, ter_oz)
}
# Bake the height-field sun shadow (see tershadow.frag). Cheap enough to redo whenever
# the sun moves; r3d_frame calls it again when sky_set_yaw has changed the yaw.
function terrain_bake_shadow() -> void {
if sun_dir == null { return }
if ter_shadow_tex == 0 { ter_shadow_tex = tex_target(TERRAIN_SHADOW_RES, TERRAIN_SHADOW_RES, GL_RGBA32F, GL_RGBA, GL_FLOAT, GL_LINEAR) }
if ter_shadow_prog == 0 { ter_shadow_prog = r3d_program("fullscreen.vert", "tershadow.frag", "#define NOISE_ONLY\n") }
let p = ter_shadow_prog
let fbo = gl_framebuffer()
gl_bind_framebuffer(GL_FRAMEBUFFER, fbo)
gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ter_shadow_tex, 0)
gl_viewport(0, 0, TERRAIN_SHADOW_RES, TERRAIN_SHADOW_RES)
gl_disable(GL_DEPTH_TEST)
gl_disable(GL_BLEND)
gl_use_program(p)
r3d_bind_2d(p, "u_height", 0, ter_height_tex)
u_f(gl_uniform(p, "u_half"), fi(TERRAIN_HALF))
u_v3(gl_uniform(p, "u_sun"), sun_dir)
mesh_draw(sky_fullscreen)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
let ids = gl_scratch()
ids[0] = fbo
gl_delete_framebuffers(1, ids)
ter_shadow_yaw = sky_yaw
gl_check("terrain shadow bake")
}
# height at world (x, z) — float bits, bilinear over the CPU copy
# The height and photograph scales are constants, but they were being recomputed —
# a fixed-point divide — on every call, and the cover generator calls terrain_height
# five times per candidate (once directly, four more inside slope_at) across hundreds
# of thousands of candidates per chunk. Hoisted, they cost nothing.
var ter_h_scale: int = 0
var ter_o_scale: int = 0
function terrain_height(x: int, z: int) -> int {
if ter_h_scale == 0 { ter_h_scale = fr(TERRAIN_RES, TERRAIN_HALF * 2) }
let scale = ter_h_scale
let fx = f_mul(f_add(f_sub(x, ter_ox), fi(TERRAIN_HALF)), scale)
let fz = f_mul(f_add(f_sub(z, ter_oz), fi(TERRAIN_HALF)), scale)
var ix = f_to_int(f_floor(fx)); var iz = f_to_int(f_floor(fz))
if ix < 0 { ix = 0 }; if iz < 0 { iz = 0 }
if ix > TERRAIN_RES - 2 { ix = TERRAIN_RES - 2 }; if iz > TERRAIN_RES - 2 { iz = TERRAIN_RES - 2 }
let tx = f_clamp(f_sub(fx, fi(ix)), F_ZERO, F_ONE)
let tz = f_clamp(f_sub(fz, fi(iz)), F_ZERO, F_ONE)
let h00 = ter_heights[iz * TERRAIN_RES + ix]
let h10 = ter_heights[iz * TERRAIN_RES + ix + 1]
let h01 = ter_heights[(iz + 1) * TERRAIN_RES + ix]
let h11 = ter_heights[(iz + 1) * TERRAIN_RES + ix + 1]
return f_lerp(f_lerp(h00, h10, tx), f_lerp(h01, h11, tx), tz)
}
# the same for Q16.16 callers
function terrain_height_fx(x: fixed, z: fixed) -> fixed { return f_fx(terrain_height(fl(x), fl(z))) }
# The height the terrain is DRAWN at: the cubic B-spline of the texels (heightSmooth in
# terrain.vert), not the bilinear read above. The two differ by up to half a metre on
# rough ground, which is the difference between a character standing on the meadow
# and one buried to the knee in it. Sixteen taps; for things that move, not for the
# thousands of placement queries a chunk makes.
var ter_bw: words = null
function terrain_height_smooth(x: int, z: int) -> int {
if ter_h_scale == 0 { ter_h_scale = fr(TERRAIN_RES, TERRAIN_HALF * 2) }
if ter_bw == null { ter_bw = words(8) }
let scale = ter_h_scale
let fx = f_sub(f_mul(f_add(f_sub(x, ter_ox), fi(TERRAIN_HALF)), scale), F_HALF)
let fz = f_sub(f_mul(f_add(f_sub(z, ter_oz), fi(TERRAIN_HALF)), scale), F_HALF)
let ix = f_to_int(f_floor(fx)); let iz = f_to_int(f_floor(fz))
let tx = f_clamp(f_sub(fx, fi(ix)), F_ZERO, F_ONE)
let tz = f_clamp(f_sub(fz, fi(iz)), F_ZERO, F_ONE)
# the four cubic B-spline weights of a fraction, over texels i-1 .. i+2
for a in 0 .. 2 {
var t = tx
if a == 1 { t = tz }
let t2 = f_mul(t, t); let t3 = f_mul(t2, t)
let one_t = f_sub(F_ONE, t)
let w0 = f_div(f_mul(f_mul(one_t, one_t), one_t), fi(6))
let w1 = f_div(f_add(f_sub(fi(4), f_mul(fi(6), t2)), f_mul(fi(3), t3)), fi(6))
let w3 = f_div(t3, fi(6))
let w2 = f_sub(f_sub(f_sub(F_ONE, w0), w1), w3)
ter_bw[a * 4] = w0; ter_bw[a * 4 + 1] = w1; ter_bw[a * 4 + 2] = w2; ter_bw[a * 4 + 3] = w3
}
var h = F_ZERO
for j in 0 .. 4 {
var rz = iz - 1 + j
if rz < 0 { rz = 0 }; if rz > TERRAIN_RES - 1 { rz = TERRAIN_RES - 1 }
var row = F_ZERO
for i in 0 .. 4 {
var rx = ix - 1 + i
if rx < 0 { rx = 0 }; if rx > TERRAIN_RES - 1 { rx = TERRAIN_RES - 1 }
row = f_add(row, f_mul(ter_bw[i], ter_heights[rz * TERRAIN_RES + rx]))
}
h = f_add(h, f_mul(ter_bw[4 + j], row))
}
return h
}
function terrain_load_textures() -> void {
ter_tex = words(15)
let a = r3d_assets + "/textures/"
ter_tex[0] = tex_load(a + "aerial_grass_rock_diff_2k.png", true)
ter_tex[1] = tex_load(a + "aerial_grass_rock_nor_gl_2k.png", false)
ter_tex[2] = tex_load(a + "aerial_grass_rock_arm_2k.png", false)
ter_tex[3] = tex_load(a + "grass_path_2_diff_2k.png", true)
ter_tex[4] = tex_load(a + "grass_path_2_nor_gl_2k.png", false)
ter_tex[5] = tex_load(a + "grass_path_2_arm_2k.png", false)
ter_tex[6] = tex_load(a + "gray_rocks_diff_2k.png", true)
ter_tex[7] = tex_load(a + "gray_rocks_nor_gl_2k.png", false)
ter_tex[8] = tex_load(a + "gray_rocks_arm_2k.png", false)
ter_tex[9] = tex_load(a + "snow_02_diff_2k.png", true)
ter_tex[10] = tex_load(a + "snow_02_nor_gl_2k.png", false)
ter_tex[11] = tex_load(a + "snow_02_arm_2k.png", false)
ter_tex[12] = tex_load(a + "aerial_grass_rock_disp_2k.png", false)
ter_tex[13] = tex_load(a + "cliff_side_diff_2k.png", true)
ter_tex[14] = tex_load(a + "cliff_side_nor_gl_2k.png", false)
if Os.has_env("R3D_TEXDBG") { for i in 0 .. 15 { print(`ter_tex[{string(i)}] = {string(ter_tex[i])}`) } }
}
function terrain_init() -> void {
terrain_generate()
terrain_bake_shadow()
terrain_load_textures()
cdlod_init()
ter_wire = Os.has_env("R3D_WIRE")
ter_force_far = Os.has_env("R3D_TFARONLY")
ter_no_split = Os.has_env("R3D_NOSPLIT")
ter_force_near = Os.has_env("R3D_TNEARONLY")
ter_skip = Os.has_env("R3D_NOTERRAIN")
var defs = ""
if r3d_debug_shadow { defs = "#define DEBUG_SHADOW\n" }
if Os.has_env("R3D_DEBUG_MAT") { defs = "#define DEBUG_MAT\n" }
if Os.has_env("R3D_DEBUG_NRM") { defs = "#define DEBUG_NRM\n" }
if Os.has_env("R3D_DEBUG_ALB") { defs = "#define DEBUG_ALB\n" }
# Elimination profiling. Measure these by FRAME TIME (prof_ft_report), not by the
# per-pass GPU timers: this driver's timer queries attribute a pass's fragment work
# almost arbitrarily, and will happily report a pass at a tenth of its cost.
# R3D_TFAST=1 the ground reads no sun visibility =2 every noise field at its mean
# =3 no scanned material taps =4 no survey photograph
# =5 the detailed tier at every distance =20 no photograph grain
# R3D_NOTERRAIN skips the ground entirely (what it costs); R3D_TNEARONLY / R3D_TFARONLY
# draw every patch with one tier's program (what each tier costs over a whole frame);
# R3D_NOSPLIT goes back to the single program that holds both tiers.
if Os.has_env("R3D_TFAST") { defs = defs + "#define TFAST_" + Os.env("R3D_TFAST") + "\n" }
ter_prog = r3d_program("terrain.vert", "terrain.frag", defs)
ter_prog_far = r3d_program("terrain.vert", "terrain.frag", defs + "#define FAR_ONLY\n")
ter_prog_near = r3d_program("terrain.vert", "terrain.frag", defs + "#define NEAR_ONLY\n")
ter_sun_prog = r3d_program("terrain.vert", "tersun.frag", "")
ter_far_split = fi(200)
if Os.has_env("R3D_TFAR") { ter_far_split = fi(Text.to_int(Os.env("R3D_TFAR"))) }
ter_far_band = fi(60)
if Os.has_env("R3D_TBAND") { ter_far_band = fi(Text.to_int(Os.env("R3D_TBAND"))) }
ter_snow_line = fi(880)
gl_check("terrain init")
}
# draw into the current cascade with the given light view-projection
# The terrain no longer casts into the shadow map: it shadows itself by marching its
# own height field in terrain.frag, which cannot produce the self-shadow grid a depth
# map does, and it saves drawing the whole grid five times a frame.
function terrain_draw_shadow(light_vp: words) -> void {
}
# Every per-frame uniform of one terrain program. Both tiers are bound up front so
# selection can switch between them per patch without re-binding anything but the node.
function terrain_bind_prog(p: int) -> void {
gl_use_program(p)
r3d_bind_2d(p, "u_height", 0, ter_height_tex)
r3d_bind_2d(p, "u_grass_d", 1, ter_tex[0]); r3d_bind_2d(p, "u_grass_n", 2, ter_tex[1]); r3d_bind_2d(p, "u_grass_a", 3, ter_tex[2])
# The cliff maps went with the dead cliff sample. Binding textures for uniforms the
# shader no longer declares leaves those units pointing at nothing, which the driver
# reports as an unloadable sampler and resolves as a zero texture.
# A scene with no photograph still has to bind something valid here: sampler unit
# pointed at texture 0 is an incomplete texture, which the driver reports as
# unloadable and which poisons sampling for the rest of the unit's stage.
var orthotex = ter_ortho_tex
if orthotex == 0 { orthotex = ter_tex[0] }
r3d_bind_2d(p, "u_ortho", 4, orthotex)
var oon = F_ZERO
if ter_ortho_tex != 0 { oon = F_ONE }
u_f(gl_uniform(p, "u_ortho_on"), oon)
r3d_bind_2d(p, "u_rock_d", 7, ter_tex[6]); r3d_bind_2d(p, "u_rock_n", 8, ter_tex[7]); r3d_bind_2d(p, "u_rock_a", 9, ter_tex[8])
r3d_bind_2d(p, "u_snow_d", 10, ter_tex[9])
if ter_carpet != 0 { r3d_bind_2d(p, "u_carpet", 11, ter_carpet); u_f(gl_uniform(p, "u_carpet_on"), F_ONE) }
else { r3d_bind_2d(p, "u_carpet", 11, ter_tex[0]); u_f(gl_uniform(p, "u_carpet_on"), F_ZERO) }
u_f(gl_uniform(p, "u_half"), fi(TERRAIN_HALF))
u_f(gl_uniform(p, "u_texel"), fr(1, TERRAIN_RES))
u_f(gl_uniform(p, "u_snow_line"), ter_snow_line)
var lake = fl(-100000.0)
if ter_lake_ex != 0 { lake = ter_lake_level }
u_f(gl_uniform(p, "u_lake_level"), lake)
u_mat4(gl_uniform(p, "u_view"), cam_view)
u_mat4(gl_uniform(p, "u_proj"), cam_proj)
u_f2(gl_uniform(p, "u_origin"), ter_ox, ter_oz)
u_f(gl_uniform(p, "u_far_split"), ter_far_split)
u_f(gl_uniform(p, "u_far_band"), ter_far_band)
sky_bind_lighting(p)
shadow_bind(p)
fog_bind(p)
u_v3(gl_uniform(p, "u_cam_pos"), cam_pos)
u_f(gl_uniform(p, "u_grid"), fi(CD_G))
# The ground reads its sun visibility out of the buffer tersun.frag filled, and has no
# use for the cascade array shadow_bind just put on this unit; leaving both bound under
# one unit is undefined ground, so the array comes off first.
gl_active_texture(GL_TEXTURE0 + 15)
gl_bind_texture(GL_TEXTURE_2D_ARRAY, 0)
r3d_bind_2d(p, "u_sunshadow", 15, ter_sun_tex)
}
# The visibility buffer for the size being drawn into. The reflection is rendered at its
# own (smaller) size, so it keeps its own.
#
# Neither owns a depth buffer. The pass BORROWS the depth the frame is about to be drawn
# with, so its rasterisation doubles as a depth prepass — and a borrowed texture must
# never be written into the Target, because a Target deletes whatever its `depth` names
# when it is freed. Storing it there deleted the frame's own depth buffer on the first
# resize (post_init had already made the replacement, and GL hands the freed name straight
# back, so the new one was deleted instead of the old). The scene framebuffer lost its
# depth attachment, the sky's fullscreen quad had nothing left to fail against, and it
# painted over the whole valley — with "gl error 1286" every frame from the passes whose
# attachment now named a deleted texture.
function terrain_sun_target(w: int, h: int) -> Target {
var t = ter_sun_tgt
if ter_reflect { t = ter_sun_refl }
if t == null or t.w != w or t.h != h {
target_free(t) # owns its colour, and nothing else
t = target_new(w, h, GL_R8, GL_RED, GL_UNSIGNED_BYTE, false, GL_NEAREST)
if ter_reflect { ter_sun_refl = t } else { ter_sun_tgt = t }
}
return t
}
# Rasterise the patches once with the small shader that reads the cascades (tersun.frag).
function terrain_sun_pass(w: int, h: int, depth: int) -> Target {
let t = terrain_sun_target(w, h)
# Attach the frame's depth afresh every pass. It is a different texture every time the
# screen-sized buffers are rebuilt — a resize, a fullscreen change — and an attachment
# naming a texture that has been deleted leaves this framebuffer incomplete, which is
# an error per draw and a pass that silently does nothing. One call a pass is cheaper
# than any scheme for noticing.
target_bind(t)
gl_framebuffer_texture2d(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depth, 0)
if not ter_sun_checked {
ter_sun_checked = true
let st = gl_check_framebuffer_status(GL_FRAMEBUFFER)
if st != GL_FRAMEBUFFER_COMPLETE { print(`r3d: sun-visibility framebuffer incomplete {st}`) }
}
gl_enable(GL_DEPTH_TEST)
gl_depth_func(GL_LESS)
gl_depth_mask(1)
# the depth is the frame's own and was cleared with it; only the visibility is cleared
gl_clear_color(1.0, 1.0, 1.0, 1.0) # unshadowed where nothing is drawn
gl_clear(GL_COLOR_BUFFER_BIT)
let p = ter_sun_prog
gl_use_program(p)
r3d_bind_2d(p, "u_height", 0, ter_height_tex)
u_f(gl_uniform(p, "u_half"), fi(TERRAIN_HALF))
u_mat4(gl_uniform(p, "u_view"), cam_view)
u_mat4(gl_uniform(p, "u_proj"), cam_proj)
u_f2(gl_uniform(p, "u_origin"), ter_ox, ter_oz)
u_v3(gl_uniform(p, "u_cam_pos"), cam_pos)
u_f(gl_uniform(p, "u_grid"), fi(CD_G))
u_f(gl_uniform(p, "u_far_split"), ter_far_split)
u_f(gl_uniform(p, "u_far_band"), ter_far_band)
u_f(gl_uniform(p, "u_clip_y"), r3d_clip_y)
shadow_bind(p)
sky_bind_lighting(p)
ter_sun_pass = true
gl_bind_vertex_array(cd_mesh.vao)
cdlod_select(CD_LEVELS - 1, 0, 0)
ter_sun_pass = false
if Os.has_env("R3D_DUMP_SUN") and not ter_sun_dumped and not ter_reflect { ter_sun_dumped = true; tex_dump(t.color, w, h, "build/dbg_sun.ppm") }
return t
}
function terrain_sun_prepare() -> void {
ter_sun_tex = terrain_sun_pass(post_w, post_h, post_hdr.depth).color
ter_sun_done = true
}
function terrain_draw() -> void {
if ter_skip { return }
# The sun visibility first, into its own buffer; the shading pass looks it up per pixel.
# The pass binds its own framebuffer, so the caller's target is restored afterwards —
# the reflection's, or the scene's, without disturbing what is already drawn in it.
var vw = post_w
var vh = post_h
if ter_reflect { vw = water_refl.w; vh = water_refl.h }
if not ter_sun_done {
var dep = post_hdr.depth
if ter_reflect { dep = water_refl.depth }
ter_sun_tex = terrain_sun_pass(vw, vh, dep).color
}
ter_sun_done = false
if ter_reflect { target_bind(water_refl) }
else {
target_bind(post_hdr)
if post_ms_fbo != 0 { gl_bind_framebuffer(GL_FRAMEBUFFER, post_ms_fbo) }
}
gl_enable(GL_DEPTH_TEST)
# the prepass already laid this geometry's depth down: only the frontmost fragment of
# each pixel has anything to shade, and it meets that depth exactly
gl_depth_func(GL_LEQUAL)
gl_depth_mask(1)
terrain_bind_prog(ter_prog)
terrain_bind_prog(ter_prog_far)
terrain_bind_prog(ter_prog_near)
ter_prog_cur = 0
cd_draws = 0
cd_far_draws = 0
cd_near_draws = 0
gl_bind_vertex_array(cd_mesh.vao)
if ter_wire { gl_polygon_mode(GL_FRONT_AND_BACK, GL_LINE) }
cdlod_select(CD_LEVELS - 1, 0, 0)
if ter_wire { gl_polygon_mode(GL_FRONT_AND_BACK, GL_FILL) }
gl_depth_func(GL_LESS)
if r3d_debug and not ter_printed { ter_printed = true; print(`cdlod patches drawn: {cd_draws} (far {cd_far_draws}, near {cd_near_draws}, band {cd_draws - cd_far_draws - cd_near_draws})`) }
}
var ter_wire: bool = false
var ter_printed: bool = false
# ---- CDLOD --------------------------------------------------------------------------
# One 32x32 patch mesh (a_xz in 0..1) drawn once per selected quadtree node; the vertex
# shader places, scales and morphs it. Levels are drawn out to cd_range[k] = 48 * 2^k m,
# so cells are 1 m within 48 m, 2 m to 96 m, 4 m to 192 m ... 256 m at the root.
function cdlod_init() -> void {
let m = new Mesh
m.vao = gl_vao()
let n = CD_G + 1
let v = gl_floats(n * n * 2)
var k = 0
for j in 0 .. n { for i in 0 .. n { gl_put_bits(v, k, fr(i, CD_G)); gl_put_bits(v, k + 1, fr(j, CD_G)); k += 2 } }
m.vbo = gl_buffer()
gl_bind_buffer(GL_ARRAY_BUFFER, m.vbo)
gl_buffer_data(GL_ARRAY_BUFFER, gl_bytes_of(n * n * 2), v, GL_STATIC_DRAW)
gl_enable_vertex_attrib_array(0); gl_vertex_attrib_pointer(0, 2, GL_FLOAT, 0, 8, null)
free(v)
let ni = CD_G * CD_G * 6
let idx = words(ni)
k = 0
for j in 0 .. CD_G {
for i in 0 .. CD_G {
let a = j * n + i
idx[k] = a; idx[k + 1] = a + n; idx[k + 2] = a + 1
idx[k + 3] = a + 1; idx[k + 4] = a + n; idx[k + 5] = a + n + 1
k += 6
}
}
m.ebo = gl_buffer()
gl_bind_buffer(GL_ELEMENT_ARRAY_BUFFER, m.ebo)
gl_buffer_data(GL_ELEMENT_ARRAY_BUFFER, ni * 4, idx, GL_STATIC_DRAW)
free(idx)
m.count = ni
gl_bind_vertex_array(0)
cd_mesh = m
cd_range = words(CD_LEVELS)
var r = fi(48)
if Os.has_env("R3D_CD_R0") { r = fi(Text.to_int(Os.env("R3D_CD_R0"))) }
for l in 0 .. CD_LEVELS { cd_range[l] = r; r = f_mul(r, F_TWO) }
cdlod_bounds()
}
# min/max height per patch at every level, from the CPU copy of the height field
function cdlod_bounds() -> void {
cd_min = new []words; cd_max = new []words
let t = TERRAIN_RES / CD_LEAVES # texels per leaf patch side
var n = CD_LEAVES
var lo = words(n * n); var hi = words(n * n)
for j in 0 .. n {
for i in 0 .. n {
var mn = fi(100000); var mx = fi(-100000)
for y in 0 .. t + 1 {
let ty = min(j * t + y, TERRAIN_RES - 1)
for x in 0 .. t + 1 {
let tx = min(i * t + x, TERRAIN_RES - 1)
let h = ter_heights[ty * TERRAIN_RES + tx]
mn = f_min(mn, h); mx = f_max(mx, h)
}
}
lo[j * n + i] = mn; hi[j * n + i] = mx
}
}
push(cd_min, lo); push(cd_max, hi)
while n > 1 {
let m = n / 2
let plo = words(m * m); let phi = words(m * m)
for j in 0 .. m {
for i in 0 .. m {
let a = (2 * j) * n + 2 * i
plo[j * m + i] = f_min(f_min(lo[a], lo[a + 1]), f_min(lo[a + n], lo[a + n + 1]))
phi[j * m + i] = f_max(f_max(hi[a], hi[a + 1]), f_max(hi[a + n], hi[a + n + 1]))
}
}
push(cd_min, plo); push(cd_max, phi)
lo = plo; hi = phi; n = m
}
}
# does the patch's box come within r of the camera?
function cd_within(x0: int, z0: int, size: int, ymin: int, ymax: int, r: int) -> bool {
let dx = f_max(f_max(f_sub(x0, cam_pos[0]), f_sub(cam_pos[0], f_add(x0, size))), F_ZERO)
let dz = f_max(f_max(f_sub(z0, cam_pos[2]), f_sub(cam_pos[2], f_add(z0, size))), F_ZERO)
let dy = f_max(f_max(f_sub(ymin, cam_pos[1]), f_sub(cam_pos[1], ymax)), F_ZERO)
return f_ls(f_add(f_add(f_mul(dx, dx), f_mul(dz, dz)), f_mul(dy, dy)), f_mul(r, r))
}
# is the patch's box entirely inside r of the camera? (its farthest corner is within r)
function cd_inside(x0: int, z0: int, size: int, ymin: int, ymax: int, r: int) -> bool {
let x1 = f_add(x0, size)
let z1 = f_add(z0, size)
let dx = f_max(f_abs(f_sub(cam_pos[0], x0)), f_abs(f_sub(cam_pos[0], x1)))
let dz = f_max(f_abs(f_sub(cam_pos[2], z0)), f_abs(f_sub(cam_pos[2], z1)))
let dy = f_max(f_abs(f_sub(cam_pos[1], ymin)), f_abs(f_sub(cam_pos[1], ymax)))
return f_ls(f_add(f_add(f_mul(dx, dx), f_mul(dz, dz)), f_mul(dy, dy)), f_mul(r, r))
}
function cdlod_draw(level: int, ix: int, iz: int) -> void {
let size = fi(32 << level)
let x0 = f_add(f_sub(ter_ox, fi(TERRAIN_HALF)), f_mul(fi(ix), size))
let z0 = f_add(f_sub(ter_oz, fi(TERRAIN_HALF)), f_mul(fi(iz), size))
# Which tier can run inside this patch. A patch that never comes within the split takes
# the cheap tier at every pixel; one that lies wholly inside it takes the detailed tier
# at every pixel. Only a patch that straddles the band needs the program that holds both
# and cross-fades between them — and there are few of those, one ring of them.
if ter_sun_pass {
let t = gl_scratch()
t[0] = x0; t[1] = z0; t[2] = size
gl_uniform3fv(gl_uniform(ter_sun_prog, "u_node"), 1, t)
var st0 = F_ZERO
if level > 0 { st0 = cd_range[level - 1] }
u_f2(gl_uniform(ter_sun_prog, "u_morph"), f_lerp(st0, cd_range[level], fl(0.7)), cd_range[level])
gl_draw_elements(GL_TRIANGLES, cd_mesh.count, GL_UNSIGNED_INT, null)
return
}
let n = CD_LEAVES >> level
let ymin = cd_min[level][iz * n + ix]
let ymax = cd_max[level][iz * n + ix]
var p = ter_prog
if not cd_within(x0, z0, size, ymin, ymax, f_add(ter_far_split, ter_far_band)) { p = ter_prog_far }
else if cd_inside(x0, z0, size, ymin, ymax, f_sub(ter_far_split, ter_far_band)) { p = ter_prog_near }
if ter_force_far { p = ter_prog_far }
if ter_no_split { p = ter_prog }
if ter_force_near { p = ter_prog_near }
if p == ter_prog_far { cd_far_draws += 1 }
if p == ter_prog_near { cd_near_draws += 1 }
if p != ter_prog_cur { gl_use_program(p); ter_prog_cur = p }
let t = gl_scratch()
t[0] = x0; t[1] = z0; t[2] = size
gl_uniform3fv(gl_uniform(p, "u_node"), 1, t)
var start = F_ZERO
if level > 0 { start = cd_range[level - 1] }
start = f_lerp(start, cd_range[level], fl(0.7))
u_f2(gl_uniform(p, "u_morph"), start, cd_range[level])
gl_draw_elements(GL_TRIANGLES, cd_mesh.count, GL_UNSIGNED_INT, null)
cd_draws += 1
}
# Strugar's selection: a node is drawn at its own level unless it is close enough to need
# its children, in which case each child either selects itself or is drawn at this level.
function cdlod_select(level: int, ix: int, iz: int) -> bool {
let n = CD_LEAVES >> level
let size = fi(32 << level)
let x0 = f_add(f_sub(ter_ox, fi(TERRAIN_HALF)), f_mul(fi(ix), size))
let z0 = f_add(f_sub(ter_oz, fi(TERRAIN_HALF)), f_mul(fi(iz), size))
let ymin = cd_min[level][iz * n + ix]
let ymax = cd_max[level][iz * n + ix]
if not cd_within(x0, z0, size, ymin, ymax, cd_range[level]) { return false }
let half = f_mul(size, F_HALF)
let cy = f_mul(f_add(ymin, ymax), F_HALF)
let rad = f_sqrt(f_add(f_mul(f_mul(half, half), F_TWO), f_mul(f_mul(f_sub(ymax, cy), f_sub(ymax, cy)), F_ONE)))
if not cam_sphere_visible(f_add(x0, half), cy, f_add(z0, half), f_add(rad, fi(2))) { return true }
if level == 0 { cdlod_draw(0, ix, iz); return true }
if not cd_within(x0, z0, size, ymin, ymax, cd_range[level - 1]) { cdlod_draw(level, ix, iz); return true }
for c in 0 .. 4 {
let cx = ix * 2 + (c & 1); let cz = iz * 2 + (c >> 1)
if not cdlod_select(level - 1, cx, cz) { cdlod_draw(level - 1, cx, cz) }
}
return true
}

View file

@ -0,0 +1,472 @@
# ============================================================================
# texture.ludic — images for the GPU: PNG (8- and 16-bit, any colour type) and
# Radiance .hdr (RGBE) decoding straight into OpenGL textures.
#
# The engine's own PNG reader (image.ludic) expands to 8-bit 0xAARRGGBB for the
# 2D framebuffer; a renderer wants the file's real sample depth — normal and
# displacement maps ship as 16-bit — so this decoder keeps 16-bit samples and
# uploads them as GL_UNSIGNED_SHORT (big-endian, with GL_UNPACK_SWAP_BYTES) into
# RGB16 / R16 textures, and 8-bit ones into sRGB8 or RGB8 as the caller says.
# ============================================================================
const GL_TEXTURE_MAX_ANISOTROPY_EXT: int = 0x84FE
var tex_w: int = 0 # the last decoded image
var tex_h: int = 0
var tex_channels: int = 0
var tex_depth: int = 0 # bits per sample (8 or 16)
var tex_file_len: int = 0
var tex_anisotropy: fixed = 16.0
function r3d_read_file(path: pointer) -> pointer {
let f = file_open(path, "rb")
if f == null { return null }
file_seek(f, 0, 2)
let n = file_tell(f)
file_seek(f, 0, 0)
if n <= 0 { file_close(f); return null }
let buf = bytes(n + 8)
file_read(f, buf, n)
file_close(f)
tex_file_len = n
return buf
}
function be32(b: pointer, at: int) -> int {
return (b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]
}
function tag4(b: pointer, at: int, a: int, c: int, d: int, e: int) -> bool {
return b[at] == a and b[at + 1] == c and b[at + 2] == d and b[at + 3] == e
}
# Decode a PNG into tightly packed scanlines of raw samples (PNG byte order:
# 16-bit samples big-endian). Sets tex_w / tex_h / tex_channels / tex_depth.
# Indexed and sub-byte greyscale files are expanded to 8-bit RGB / grey.
# Reverse one scanline's PNG filter in place (spec 9.2). The filter type is
# loop-invariant, so it is resolved once here rather than per byte, and the
# leading `fbpp` bytes (where the left neighbour is zero by definition) run as
# their own prologue instead of costing a bounds test on every byte of the image.
# The caller keeps a zeroed scanline in front of row 0, so `prev` is always a real
# row and every filter has exactly one code path — no first-row special cases to
# get wrong or to leave untested.
function png_unfilter(raw: pointer, cur: int, prev: int, stride: int, fbpp: int, ft: int) -> void {
if ft == 0 { return }
var first = fbpp
if first > stride { first = stride }
var x = 0
if ft == 1 {
x = fbpp
while x < stride { raw[cur + x] = ((raw[cur + x] + raw[cur + x - fbpp]) & 255); x += 1 }
return
}
if ft == 2 {
x = 0
while x < stride { raw[cur + x] = ((raw[cur + x] + raw[prev + x]) & 255); x += 1 }
return
}
if ft == 3 {
x = 0
while x < first { raw[cur + x] = ((raw[cur + x] + raw[prev + x] / 2) & 255); x += 1 }
while x < stride { raw[cur + x] = ((raw[cur + x] + (raw[cur + x - fbpp] + raw[prev + x]) / 2) & 255); x += 1 }
return
}
if ft == 4 {
x = 0
while x < first { raw[cur + x] = ((raw[cur + x] + raw[prev + x]) & 255); x += 1 }
while x < stride {
let a = raw[cur + x - fbpp]
let b = raw[prev + x]
let c = raw[prev + x - fbpp]
let p = a + b - c
let pa = abs(p - a)
let pb = abs(p - b)
let pc = abs(p - c)
var pick = c
if pb <= pc { pick = b }
if pa <= pb and pa <= pc { pick = a }
raw[cur + x] = ((raw[cur + x] + pick) & 255)
x += 1
}
}
}
function png_decode(path: pointer) -> pointer {
let d = r3d_read_file(path)
if d == null { print(`png: cannot read {path}`); return null }
let size = tex_file_len
if size < 8 or d[0] != 137 or d[1] != 80 { free(d); print(`png: not a png: {path}`); return null }
var w = 0; var h = 0; var bd = 0; var ct = 0
let plte = bytes(768)
let idat = bytes(size)
var idlen = 0
var i = 8
var done = false
while not done {
if i + 8 > size { done = true; continue }
let ln = be32(d, i)
let typ = i + 4
let body = i + 8
if ln < 0 or body + ln > size { done = true; continue }
if tag4(d, typ, 73, 72, 68, 82) { w = be32(d, body); h = be32(d, body + 4); bd = d[body + 8]; ct = d[body + 9] }
if tag4(d, typ, 80, 76, 84, 69) { let m = min(ln, 768); for k in 0 .. m { plte[k] = d[body + k] } }
if tag4(d, typ, 73, 68, 65, 84) { mem_copy(mem_off(idat, idlen), mem_off(d, body), ln); idlen += ln }
if tag4(d, typ, 73, 69, 78, 68) { done = true }
i = i + 12 + ln
}
if w <= 0 or h <= 0 { free(d); free(idat); free(plte); return null }
var channels = 1
if ct == 2 { channels = 3 }
if ct == 4 { channels = 2 }
if ct == 6 { channels = 4 }
let bppbits = bd * channels
var fbpp = (bppbits + 7) / 8
if fbpp < 1 { fbpp = 1 }
let stride = (w * bppbits + 7) / 8
let rawlen = h * (stride + 1)
# one zeroed scanline in front of the data, so row 0's "row above" is real
let raw = bytes(stride + rawlen + 8)
for z in 0 .. stride { raw[z] = 0 }
if z_uncompress(idat, idlen, mem_off(raw, stride), rawlen) < 0 { free(d); free(idat); free(raw); free(plte); print(`png: inflate failed: {path}`); return null }
free(d); free(idat)
# reverse the per-scanline filters in place, then pack rows without the filter byte
var y = 0
while y < h {
let line = stride + y * (stride + 1)
png_unfilter(raw, line + 1, line + 1 - (stride + 1), stride, fbpp, raw[line])
y += 1
}
var out: pointer = null
if (ct == 3) or (bd < 8) {
# expand palette / sub-byte grey to 8-bit RGB (palette) or 8-bit grey
let maxv = (1 << bd) - 1
var oc = 1
if ct == 3 { oc = 3 }
out = bytes(w * h * oc)
for yy in 0 .. h {
let row = stride + yy * (stride + 1) + 1
for x in 0 .. w {
let bp = x * bd
let idx = ((raw[row + bp / 8] >> (8 - bd - bp % 8)) & maxv)
if ct == 3 { out[(yy * w + x) * 3] = plte[idx * 3]; out[(yy * w + x) * 3 + 1] = plte[idx * 3 + 1]; out[(yy * w + x) * 3 + 2] = plte[idx * 3 + 2] }
else { out[yy * w + x] = idx * 255 / maxv }
}
}
channels = oc
bd = 8
free(raw)
} else {
out = bytes(h * stride + 8)
for yy in 0 .. h { mem_copy(mem_off(out, yy * stride), mem_off(raw, stride + yy * (stride + 1) + 1), stride) }
free(raw)
}
free(plte)
tex_w = w; tex_h = h; tex_channels = channels; tex_depth = bd
return out
}
# Edge padding for cut-out atlases: pixels darker than `thresh` (the unused
# background) take the mean of their lit neighbours, repeated `passes` times, so
# mipmaps and bilinear taps never pull black into the blades. 8-bit RGB/RGBA only.
function tex_dilate(px: pointer, thresh: int, passes: int) -> void {
if tex_depth != 8 or tex_channels < 3 { return }
let w = tex_w; let h = tex_h; let c = tex_channels
let mask = bytes(w * h)
var i = 0
while i < w * h { let o = i * c; if px[o] + px[o + 1] + px[o + 2] < thresh { mask[i] = 1 } else { mask[i] = 0 }; i += 1 }
let next = bytes(w * h)
for pass in 0 .. passes {
mem_copy(next, mask, w * h)
var y = 0
while y < h {
var x = 0
while x < w {
let k = y * w + x
if mask[k] == 1 {
var r = 0; var g = 0; var b = 0; var n = 0
if x > 0 and mask[k - 1] == 0 { let o = (k - 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if x < w - 1 and mask[k + 1] == 0 { let o = (k + 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y > 0 and mask[k - w] == 0 { let o = (k - w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y < h - 1 and mask[k + w] == 0 { let o = (k + w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if n > 0 { let o = k * c; px[o] = r / n; px[o + 1] = g / n; px[o + 2] = b / n; next[k] = 0 }
}
x += 1
}
y += 1
}
mem_copy(mask, next, w * h)
}
free(mask); free(next)
}
# Upload the last-decoded samples as a 2D texture. srgb: colour data (8-bit only).
function tex_upload(px: pointer, srgb: bool, mips: bool) -> int {
let id = gl_texture()
gl_bind_texture(GL_TEXTURE_2D, id)
var fmt = GL_RED
if tex_channels == 2 { fmt = GL_RG }
if tex_channels == 3 { fmt = GL_RGB }
if tex_channels == 4 { fmt = GL_RGBA }
var ifmt = GL_R8
var ty = GL_UNSIGNED_BYTE
if tex_depth == 16 {
ty = GL_UNSIGNED_SHORT
ifmt = GL_R16
if tex_channels == 2 { ifmt = GL_RG16 }
if tex_channels == 3 { ifmt = GL_RGB16 }
if tex_channels == 4 { ifmt = GL_RGBA16 }
gl_pixel_storei(GL_UNPACK_SWAP_BYTES, 1)
} else {
if tex_channels == 2 { ifmt = GL_RG8 }
if tex_channels == 3 { ifmt = GL_RGB8; if srgb { ifmt = GL_SRGB8 } }
if tex_channels == 4 { ifmt = GL_RGBA8; if srgb { ifmt = GL_SRGB8_ALPHA8 } }
gl_pixel_storei(GL_UNPACK_SWAP_BYTES, 0)
}
gl_pixel_storei(GL_UNPACK_ALIGNMENT, 1)
gl_tex_image2d(GL_TEXTURE_2D, 0, ifmt, tex_w, tex_h, 0, fmt, ty, px)
gl_pixel_storei(GL_UNPACK_SWAP_BYTES, 0)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
if mips {
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
gl_generate_mipmap(GL_TEXTURE_2D)
gl_tex_parameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, tex_anisotropy)
} else {
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
}
return id
}
# Load a PNG as a mipmapped, anisotropic texture (0 on failure). srgb for albedo.
function tex_load(path: pointer, srgb: bool) -> int { return tex_load_ex(path, srgb, 0) }
# ... with `dilate` passes of edge padding for a cut-out atlas (0 = none)
function tex_load_ex(path: pointer, srgb: bool, dilate: int) -> int {
let px = png_decode(path)
if px == null { return 0 }
if dilate > 0 { tex_dilate(px, 60, dilate) }
let id = tex_upload(px, srgb, true)
free(px)
return id
}
# A small solid-colour fallback texture (linear rgb 0..255), for missing maps.
function tex_solid(r: int, g: int, b: int, a: int) -> int {
let px = bytes(16)
for i in 0 .. 4 { px[i * 4] = r; px[i * 4 + 1] = g; px[i * 4 + 2] = b; px[i * 4 + 3] = a }
tex_w = 2; tex_h = 2; tex_channels = 4; tex_depth = 8
let id = tex_upload(px, false, false)
free(px)
return id
}
# ---- Radiance .hdr (RGBE, new-style RLE) -> RGB float bits -------------------------
var hdr_max_lum: int = 0 # float bits of the brightest texel (sun finding)
var hdr_max_x: int = 0
var hdr_max_y: int = 0
var hdr_sun_r: int = 0 # irradiance (float bits) of everything above the IBL clip: the sun
var hdr_sun_g: int = 0
var hdr_sun_b: int = 0
var hdr_clip: int = 0 # float bits; texels above this (per channel) feed the sun, not the IBL
function hdr_decode(path: pointer) -> words {
let d = r3d_read_file(path)
if d == null { print(`hdr: cannot read {path}`); return null }
let size = tex_file_len
# header: lines until an empty line, then "-Y h +X w"
var i = 0
var blank = false
while i < size and not blank {
if d[i] == 10 and d[i + 1] == 10 { blank = true; i += 2 }
else { i += 1 }
}
# parse "-Y <h> +X <w>"
var h = 0; var w = 0
i += 3
while d[i] >= '0' and d[i] <= '9' { h = h * 10 + (d[i] - 48); i += 1 }
i += 4
while d[i] >= '0' and d[i] <= '9' { w = w * 10 + (d[i] - 48); i += 1 }
i += 1
if w <= 0 or h <= 0 { free(d); print(`hdr: bad header {path}`); return null }
let out = words(w * h * 3)
let line = bytes(w * 4)
var maxl = 0
if hdr_clip == 0 { hdr_clip = fi(20) }
var sr = F_ZERO; var sg = F_ZERO; var sb = F_ZERO
var skye = F_ZERO # sky irradiance on an upward face (clipped part only)
let dphi = f_div(f_mul(F_TWO, F_PI), fi(w))
let dth = f_div(F_PI, fi(h))
var y = 0
while y < h {
if d[i] == 2 and d[i + 1] == 2 and (d[i + 2] & 128) == 0 {
i += 4
for c in 0 .. 4 {
var x = 0
while x < w {
var n = d[i]; i += 1
if n > 128 {
n -= 128
let v = d[i]; i += 1
for k in 0 .. n { line[(x + k) * 4 + c] = v }
x += n
} else {
for k in 0 .. n { line[(x + k) * 4 + c] = d[i + k] }
i += n
x += n
}
}
}
} else {
for x in 0 .. w { for c in 0 .. 4 { line[x * 4 + c] = d[i + x * 4 + c] } }
i += w * 4
}
let sinth = f_sin(f_mul(f_add(fi(y), F_HALF), dth))
let domega = f_mul(f_mul(dphi, dth), sinth)
for x in 0 .. w {
let e = line[x * 4 + 3]
let o = (y * w + x) * 3
if e == 0 { out[o] = 0; out[o + 1] = 0; out[o + 2] = 0 }
else {
let sh = e - 136
let vr = f_ldexp(f_from_int(line[x * 4]), sh)
let vg = f_ldexp(f_from_int(line[x * 4 + 1]), sh)
let vb = f_ldexp(f_from_int(line[x * 4 + 2]), sh)
# the texture is capped at what a half-float holds; the sun is integrated uncapped
out[o] = f_min(vr, fi(60000))
out[o + 1] = f_min(vg, fi(60000))
out[o + 2] = f_min(vb, fi(60000))
let lum = f_add(f_add(vr, vg), vb)
if f_lt(maxl, lum) != 0 { maxl = lum; hdr_max_x = x; hdr_max_y = y }
if y < h / 2 { skye = f_add(skye, f_mul(f_mul(f_min(vg, hdr_clip), f_cos(f_mul(f_add(fi(y), F_HALF), dth))), domega)) }
if f_lt(hdr_clip, vg) != 0 or f_lt(hdr_clip, vr) != 0 {
sr = f_add(sr, f_mul(f_max(f_sub(vr, hdr_clip), F_ZERO), domega))
sg = f_add(sg, f_mul(f_max(f_sub(vg, hdr_clip), F_ZERO), domega))
sb = f_add(sb, f_mul(f_max(f_sub(vb, hdr_clip), F_ZERO), domega))
}
}
}
y += 1
}
free(line); free(d)
tex_w = w; tex_h = h; tex_channels = 3; tex_depth = 32
hdr_max_lum = maxl
hdr_sun_r = sr; hdr_sun_g = sg; hdr_sun_b = sb
print(`hdr: peak/1000 {f_fx(f_div(maxl, fi(1000)))} sky irradiance(up) {f_fx(skye)} sun irradiance {f_fx(sg)} (Q16.16 = /65536)`)
return out
}
# Load an equirectangular .hdr as an RGB16F texture with mips (clamped in v).
function tex_load_hdr(path: pointer) -> int {
let px = hdr_decode(path)
if px == null { return 0 }
let id = gl_texture()
gl_bind_texture(GL_TEXTURE_2D, id)
gl_pixel_storei(GL_UNPACK_ALIGNMENT, 4)
gl_tex_image2d(GL_TEXTURE_2D, 0, GL_RGB16F, tex_w, tex_h, 0, GL_RGB, GL_FLOAT, px)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
gl_generate_mipmap(GL_TEXTURE_2D)
free(px)
return id
}
# An empty render-target texture of the given internal format (no mips, clamped).
function tex_target(w: int, h: int, ifmt: int, fmt: int, ty: int, filter: int) -> int {
let id = gl_texture()
gl_bind_texture(GL_TEXTURE_2D, id)
gl_tex_image2d(GL_TEXTURE_2D, 0, ifmt, w, h, 0, fmt, ty, null)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter)
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter)
return id
}
var tex_dump_alpha: bool = false
# Debug: the brightest texel of an RGBA float texture and where it is.
function tex_max(tex: int, w: int, h: int, tag: pointer) -> void {
let buf = words(w * h * 4)
gl_bind_texture(GL_TEXTURE_2D, tex)
gl_pixel_storei(GL_PACK_ALIGNMENT, 4)
gl_get_tex_image(GL_TEXTURE_2D, 0, GL_RGBA, GL_FLOAT, buf)
var best = F_ZERO; var bx = 0; var by = 0
var i = 0
while i < w * h {
let v = f_max(buf[i * 4], f_max(buf[i * 4 + 1], buf[i * 4 + 2]))
if f_gt(v, best) { best = v; bx = i % w; by = i / w }
i += 1
}
print(`{tag}: max {f_fx(f_div(best, fi(100)))}/100 at {bx} {h - 1 - by} (top-down)`)
let o = (by * w + bx) * 4
let big = fi(65000)
let finite = f_ls(best, big)
print(` rgba (clamped/100): {f_fx(f_div(f_min(buf[o], big), fi(100)))} {f_fx(f_div(f_min(buf[o + 1], big), fi(100)))} {f_fx(f_div(f_min(buf[o + 2], big), fi(100)))} {f_fx(f_min(buf[o + 3], big))} finite {finite} bits {buf[o]}`)
free(buf)
}
# Debug: write a 2D texture's level 0 (RGBA8, alpha dropped) as a binary PPM.
function tex_dump(tex: int, w: int, h: int, path: pointer) -> void {
let f = file_open(path, "wb")
if f == null { return }
let buf = bytes(w * h * 4)
gl_bind_texture(GL_TEXTURE_2D, tex)
gl_pixel_storei(GL_PACK_ALIGNMENT, 1)
gl_get_tex_image(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, buf)
let hdr = `P6\n{w} {h}\n255\n`
file_write(f, hdr, len(hdr))
let row = bytes(w * 3)
for y in 0 .. h {
for x in 0 .. w {
row[x * 3] = buf[(y * w + x) * 4]; row[x * 3 + 1] = buf[(y * w + x) * 4 + 1]; row[x * 3 + 2] = buf[(y * w + x) * 4 + 2]
if tex_dump_alpha { let a = buf[(y * w + x) * 4 + 3]; row[x * 3] = a; row[x * 3 + 1] = a; row[x * 3 + 2] = a }
}
file_write(f, row, w * 3)
}
file_close(f)
free(buf); free(row)
}
# A binary PPM (P6, what Gl.screenshot writes) as an RGB8 texture, box-filtered down by
# `shrink` (a photo thumbnail); 0 when the file is missing.
function tex_load_ppm(path: pointer, shrink: int) -> int {
let d = r3d_read_file(path)
if d == null { return 0 }
let size = tex_file_len
var i = 2
var w = 0; var h = 0; var mx = 0
var field = 0
while i < size and field < 3 {
while i < size and (d[i] == 32 or d[i] == 10 or d[i] == 13 or d[i] == 9) { i += 1 }
var v = 0
while i < size and d[i] >= '0' and d[i] <= '9' { v = v * 10 + (d[i] - 48); i += 1 }
if field == 0 { w = v } else if field == 1 { h = v } else { mx = v }
field += 1
}
i += 1
if w <= 0 or h <= 0 or i + w * h * 3 > size { free(d); return 0 }
var k = shrink
if k < 1 { k = 1 }
let ow = w / k; let oh = h / k
let px = bytes(ow * oh * 3)
for y in 0 .. oh {
for x in 0 .. ow {
var r = 0; var g = 0; var b = 0
for yy in 0 .. k { for xx in 0 .. k {
let o = i + ((y * k + yy) * w + x * k + xx) * 3
r += d[o]; g += d[o + 1]; b += d[o + 2]
} }
let n = k * k
let q = (y * ow + x) * 3
px[q] = r / n; px[q + 1] = g / n; px[q + 2] = b / n
}
}
free(d)
tex_w = ow; tex_h = oh; tex_channels = 3; tex_depth = 8
let id = tex_upload(px, true, false)
free(px)
return id
}

View file

@ -0,0 +1,125 @@
# ============================================================================
# water.ludic — still water for the valley floor: a level plane over a region,
# drawn after the opaque scene, showing only where the ground lies below it.
# Sky reflection with fresnel, sun glitter, scrolling ripple normals, a depth
# tinted body read from the scene depth, and soft shores.
# ============================================================================
var water_mesh: Mesh = null
var water_prog: int = 0
var water_level: int = 0
var water_cx: int = 0
var water_cz: int = 0
var water_ex: int = 0
var water_ez: int = 0
var water_on: bool = false
var water_refl: Target = null # the world mirrored in the surface, half resolution
var water_refl_div: int = 2 # R3D_REFLDIV overrides: 2 = half res, 4 = quarter
var water_saved: words = null # the real camera's matrices, restored after the pass
var water_dumped: bool = false
# Render the scene through a camera mirrored in the water plane into water_refl,
# clipping everything below the surface; terrain, scattered layers and sky.
function water_reflection_pass() -> void {
if water_refl == null {
if Os.has_env("R3D_REFLDIV") { water_refl_div = Text.to_int(Os.env("R3D_REFLDIV")) }
if water_refl != null { target_free(water_refl) }
water_refl = target_new(gl_w / water_refl_div, gl_h / water_refl_div, GL_RGBA16F, GL_RGBA, GL_HALF_FLOAT, true, GL_LINEAR)
water_saved = words(16 * 4 + 3)
}
# save the camera
m4_copy(water_saved, cam_view)
m4_copy(mem_off(water_saved, 64), cam_vp)
m4_copy(mem_off(water_saved, 128), cam_inv_vp)
let sx = cam_pos[0]; let sy = cam_pos[1]; let sz = cam_pos[2]
# the mirrored camera: view' = view * R, R reflecting y about the surface (y' = 2L - y).
# R has determinant -1, so the winding flips (front faces culled below) and the image
# lands exactly where the main camera's pixels expect the reflection.
let eye = v3_new(sx, f_sub(f_mul(F_TWO, water_level), sy), sz)
let refl = m4_new()
refl[5] = f_neg1()
refl[13] = f_mul(F_TWO, water_level)
let mv = words(16)
m4_mul(mv, water_saved, refl)
m4_copy(cam_view, mv)
free(mv); free(refl)
let fwd = words(3); let up = words(3); let at = words(3)
m4_mul(cam_vp, cam_proj, cam_view)
m4_inverse(cam_inv_vp, cam_vp)
v3_copy(cam_pos, eye)
r3d_clip_y = f_sub(water_level, fl(0.05))
target_bind(water_refl)
gl_enable(GL_DEPTH_TEST)
gl_depth_func(GL_LESS)
gl_depth_mask(1)
gl_enable(GL_CULL_FACE)
gl_cull_face(GL_FRONT) # the mirror flips the winding
gl_clear_color(0.0, 0.0, 0.0, 1.0)
gl_clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
sc_freeze = true
let sb = sc_skip_blade
sc_skip_blade = true # blades are invisible at this scale in a reflection
ter_reflect = true
terrain_draw()
scene_draw()
r3d_draw_sky()
ter_reflect = false
sc_skip_blade = sb
sc_freeze = false
gl_cull_face(GL_BACK)
if Os.has_env("R3D_DUMP_REFL") and not water_dumped { water_dumped = true; tex_dump(water_refl.color, gl_w / water_refl_div, gl_h / water_refl_div, "build/dbg_refl.ppm") }
# restore
r3d_clip_y = 0xCF000000
m4_copy(cam_view, water_saved)
m4_copy(cam_vp, mem_off(water_saved, 64))
m4_copy(cam_inv_vp, mem_off(water_saved, 128))
v3_set(cam_pos, sx, sy, sz)
free(eye); free(fwd); free(up); free(at)
gl_bind_framebuffer(GL_FRAMEBUFFER, 0)
}
function water_init(level: int, cx: int, cz: int, ex: int, ez: int) -> void {
water_mesh = mesh_grid(2, F_HALF)
water_prog = r3d_program("water.vert", "water.frag", "")
water_level = level; water_cx = cx; water_cz = cz; water_ex = ex; water_ez = ez
water_on = true
}
# call after the opaque pass, before the sky: blends over the resolved depth
function water_draw(depth_tex: int) -> void {
if not water_on { return }
let p = water_prog
gl_use_program(p)
u_mat4(gl_uniform(p, "u_view"), cam_view)
u_mat4(gl_uniform(p, "u_proj"), cam_proj)
u_mat4(gl_uniform(p, "u_inv_vp"), cam_inv_vp)
u_f(gl_uniform(p, "u_level"), water_level)
u_f2(gl_uniform(p, "u_center"), water_cx, water_cz)
u_f2(gl_uniform(p, "u_extent"), water_ex, water_ez)
u_f2(gl_uniform(p, "u_screen"), fi(gl_w), fi(gl_h))
var ron = F_ZERO
if water_refl != null {
# bind on its own unit first: generating the mip chain re-binds the texture on the active unit,
# and it must not displace the depth texture the shader reads for the shore
r3d_bind_2d(p, "u_refl", 1, water_refl.color); ron = F_ONE
gl_tex_parameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
gl_generate_mipmap(GL_TEXTURE_2D)
}
r3d_bind_2d(p, "u_depth", 0, depth_tex)
r3d_bind_2d(p, "u_scene", 2, post_scene.color)
u_f(gl_uniform(p, "u_refl_on"), ron)
sky_bind_lighting(p)
shadow_bind(p)
fog_bind(p)
# Opaque. The surface composites the refracted bed itself, so there is nothing for
# hardware blending to do — and an alpha was what left see-through gaps in the foam
# and a clear band at the shore wide enough to give the plane away.
gl_disable(GL_BLEND)
gl_blend_func(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# the surface writes depth: the ambient-occlusion and temporal passes read the frame's depth,
# and the bed 9 m below the shore would otherwise darken a band along the water line
gl_depth_mask(1)
gl_disable(GL_CULL_FACE)
mesh_draw(water_mesh)
gl_disable(GL_BLEND)
}