diff --git a/assets/materials/grass_instanced.wgsl b/assets/materials/grass_instanced.wgsl index 30afce8..ebcb1ef 100644 --- a/assets/materials/grass_instanced.wgsl +++ b/assets/materials/grass_instanced.wgsl @@ -5,16 +5,13 @@ // UBO (EN-013); cascade sun shadows from sample_sun_shadow // (EN-016). // -// Canonical mesh layout (model space): -// v0 (-w, 0, 0) normal=+Z color.r=0 // root left, plane 1 -// v1 (+w, 0, 0) normal=+Z color.r=0 // root right, plane 1 -// v2 ( 0, h, 0) normal=+Z color.r=1 // tip, plane 1 -// v3 ( 0, 0,-w) normal=+X color.r=0 // root left, plane 2 -// v4 ( 0, 0,+w) normal=+X color.r=0 // root right, plane 2 -// v5 ( 0, h, 0) normal=+X color.r=1 // tip, plane 2 -// 12 indices = 4 triangles (front + back of each plane). The cross -// shape covers any horizontal viewing angle even with backface -// culling on (the Opaque bucket rule for instanced pipelines). +// Canonical mesh layout (model space) — Round-4: two-segment tapered +// blades with a bow (root pair → narrower mid pair → tip point, per +// crossed plane; 10 verts, 36 indices). See GRASS_BLADE_VERTS in +// main.ts for the authoritative table. color.r is the tip weight +// (0 root → 0.55 mid → 1 tip). The cross shape covers any horizontal +// viewing angle even with backface culling on (the Opaque bucket rule +// for instanced pipelines). // // IMPORTANT: this file is the source of truth, but the engine's // `compileMaterialInstanced` takes a WGSL string (not a path), so @@ -151,9 +148,14 @@ fn fs_main(in: VsOut) -> OpaqueOut { let shadow = sample_sun_shadow(in.world_pos); let direct = view.sun_color.rgb * direct_w * cloud * shadow; - // Tip blades catch more sun (real grass: tips bleached, roots - // shadowed). - let albedo = in.blade_tint * (0.7 + 0.3 * in.tip_weight); + // Round-4 — strong root→tip gradient (real grass: roots sit in + // shadowed olive-dark, tips are sun-bleached toward straw). The + // squared tip weight keeps the lower half dark and lets the top + // quarter bloom. + let tip2 = in.tip_weight * in.tip_weight; + let root_col = in.blade_tint * vec3(0.42, 0.50, 0.38); + let tip_col = in.blade_tint * vec3(1.12, 1.08, 0.72); + let albedo = mix(root_col, tip_col, tip2); // Transmission tint — slightly warmer than albedo for the // luminous-leaf look. Strength is grass.base.w. diff --git a/assets/materials/terrain.wgsl b/assets/materials/terrain.wgsl index a44ebbd..eed7e65 100644 --- a/assets/materials/terrain.wgsl +++ b/assets/materials/terrain.wgsl @@ -21,6 +21,10 @@ struct TerrainParams { // x = noise_freq (wraps per metre), y = slope_threshold (cos), // z = ridge_height (m), w = pale_strength knobs: vec4, + // Round-4 — x = river centre z (m), y = river half-width incl. bank + // fade start, z = bank fade width, w = waterline y (bed blend fades + // out above it). + river: vec4, }; @group(2) @binding(11) var tp: TerrainParams; @@ -81,7 +85,30 @@ fn fs_main(in: VsOut) -> OpaqueOut { // and dirt dominates. let up_dot = clamp(n.y, 0.0, 1.0); let slope_t = smoothstep(tp.knobs.y, tp.knobs.y + 0.15, up_dot); - let surface = mix(tp.dirt.rgb, grass, slope_t); + var surface = mix(tp.dirt.rgb, grass, slope_t); + + // Round-4 — macro moisture patches (~45 m wavelength): dry + // straw-olive sweeps + slightly darker lush pockets, so the field + // stops reading as one uniform green lawn. Loosely matches the + // grass blades' moisture tinting (they use the same idea at + // scatter time). + let patch_n = fbm2(in.world_pos.xz * 0.022); + let dry_t = smoothstep(0.52, 0.78, patch_n); + let wet_t = smoothstep(0.42, 0.18, patch_n); + surface = mix(surface, surface * vec3(1.30, 1.12, 0.62), dry_t * 0.55); + surface = mix(surface, surface * vec3(0.80, 0.92, 0.82), wet_t * 0.35); + + // Round-4 — riverbed: blend to wet mud inside the river channel, + // gated on world height so only the carved bed (below the + // waterline) and a thin damp bank strip take it. Pebble-scale + // noise keeps the mud from being one flat brown. + let dzr = abs(in.world_pos.z - tp.river.x); + let bed_x = 1.0 - smoothstep(tp.river.y, tp.river.y + tp.river.z, dzr); + let bed_y = 1.0 - smoothstep(tp.river.w, tp.river.w + 0.35, in.world_pos.y); + let bed_t = bed_x * bed_y; + let pebble = value_noise(in.world_pos.xz * 3.1); + let mud = vec3(0.23, 0.17, 0.11) * (0.75 + pebble * 0.5); + surface = mix(surface, mud, bed_t); // Height tint — pale near ridges. Use world.y / ridge_height as // the lerp control, capped. diff --git a/assets/materials/water.wgsl b/assets/materials/water.wgsl index 195e5d4..da8d7c7 100644 --- a/assets/materials/water.wgsl +++ b/assets/materials/water.wgsl @@ -86,13 +86,39 @@ fn micro_normal(world_xz: vec2, t: f32) -> vec3 { return normalize(vec3(nx, 4.0, nz)); // bias toward +Y } +// Round-3 — bilinear sample of the impulse field. The field is a +// non-filterable R32Float at 0.5 m/texel; the nearest-neighbour +// textureLoad the shader used before rendered every splat as hard +// half-metre squares (the "pixelated fading" while wading). wgpu +// won't filter R32Float, so the 2×2 lerp is done by hand. +fn sample_impulse(world_xz: vec2) -> f32 { + let dims = vec2(textureDimensions(impulse_tex)); + let uv = clamp(world_xz / 128.0 + vec2(0.5), vec2(0.0), vec2(1.0)); + let p = uv * dims - vec2(0.5); + let base = vec2(floor(p)); + let f = p - floor(p); + let hi = vec2(dims) - vec2(1); + let lo = vec2(0); + let v00 = textureLoad(impulse_tex, clamp(base, lo, hi), 0).r; + let v10 = textureLoad(impulse_tex, clamp(base + vec2(1, 0), lo, hi), 0).r; + let v01 = textureLoad(impulse_tex, clamp(base + vec2(0, 1), lo, hi), 0).r; + let v11 = textureLoad(impulse_tex, clamp(base + vec2(1, 1), lo, hi), 0).r; + return mix(mix(v00, v10, f.x), mix(v01, v11, f.x), f.y); +} + @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { + // Round-3 — impulse splashes agitate the surface: they boost the + // micro-normal weight locally (chop where something disturbed the + // water) instead of painting flat white (the old 85% white mix). + let imp = clamp(sample_impulse(in.world_pos.xz), 0.0, 1.0); + // Tier 4 — perturb the per-vertex normal with a fragment-level // micro-normal so close-range water reads as crinkled. let micro = micro_normal(in.world_pos.xz, frame.time); let n_base = normalize(in.world_normal); - let n = normalize(mix(n_base, micro, water_params.knobs.w)); + let micro_w = clamp(water_params.knobs.w + imp * 0.45, 0.0, 0.8); + let n = normalize(mix(n_base, micro, micro_w)); let v = normalize(view.camera_pos.xyz - in.world_pos); // Round-2 fix: derive screen UV from the fragment position instead of @@ -106,13 +132,27 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // Phase 4c — shoreline fade. Compute depth + water column FIRST // so Tier 4's Beer-Lambert absorption can use it. + // + // Round-3 fix: do NOT unproject through view.inv_proj — the engine's + // perspective is GL-convention and mat4_invert of it produces garbage + // unprojections (the same failure that collapsed the shadow cascades; + // see docs/shadow-cascade-and-ssao-fixes.md). The column this block + // used to compute was ~0 everywhere, which silently zeroed the + // absorption, caustics, shore fade AND the old rim — a root cause of + // the uniform "milk film" look. Instead linearize the two depths + // straight from the projection constants: + // z_clip = A·z_view + B·w, w_clip = -z_view + // → z_view = -B / (d + A), A = proj[2][2], B = proj[3][2] + // (The ABI's linearize_depth helper remaps d*2-1 first, which is + // wrong for this engine's depth range — don't use it here.) let depth_ix = vec2(in.clip_pos.xy); let scene_d = textureLoad(scene_depth_tex, depth_ix, 0); - let ndc_xy = vec2(screen_uv.x * 2.0 - 1.0, 1.0 - screen_uv.y * 2.0); - let floor_v = view.inv_proj * vec4(ndc_xy, scene_d, 1.0); - let surf_v = view.inv_proj * vec4(ndc_xy, in.clip_pos.z, 1.0); - let floor_z = floor_v.z / floor_v.w; - let surf_z = surf_v.z / surf_v.w; + let proj_a = view.proj[2][2]; + let proj_b = view.proj[3][2]; + let floor_z = -proj_b / (scene_d + proj_a); + let surf_z = -proj_b / (in.clip_pos.z + proj_a); + // Both z are negative (camera looks down -Z); bed is farther → more + // negative → column = surf - floor > 0 metres of water along the ray. let column = max(surf_z - floor_z, 0.0); // Refraction — perturb screen UV by wave normal xz; the offset @@ -133,7 +173,9 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let s3 = sin( cp.x * 4.3 + frame.time * 1.5); let caustic = max(s1, max(s2, s3)) * 0.5 + 0.5; // 0..1 let caustic_t = smoothstep(0.0, 0.05, column) * (1.0 - smoothstep(0.6, 1.5, column)); - refracted = refracted * mix(1.0, 1.0 + caustic * 1.4, caustic_t); + // Round-3: gain 1.4 → 0.5 — at 1.4 the caustics brightened the bed up + // to 2.4× and were a big part of the milky wash. + refracted = refracted * mix(1.0, 1.0 + caustic * 0.5, caustic_t); // Tier 4 — Beer-Lambert absorption. Refracted scene colour fades // exponentially through the water column, replaced by the tint @@ -155,10 +197,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let r = normalize(vec3(r_raw.x, max(r_raw.y, 0.08), r_raw.z)); let sky = sample_env(r, water_params.knobs.z); + // Round-3 — planar reflections (EN-011). The probe mirror-renders the + // cached-model world (trees / house / banks) across the water plane + // each frame; alpha 0 where no geometry was written, so the sky env + // shows through the gaps. Without this the water only ever reflected + // the featureless prefiltered sky — nothing anchored it to the scene. + let refl_uv = clamp(screen_uv + n.xz * 0.08, vec2(0.001), vec2(0.999)); + let planar = textureSampleLevel(planar_reflection_tex, planar_reflection_samp, refl_uv, 0.0); + let refl = mix(sky, planar.rgb, planar.a); + // Schlick Fresnel — F0 ≈ 0.02 for water, capped (see above). let cos_theta = max(dot(n, v), 0.0); let fresnel = min(0.02 + (1.0 - 0.02) * pow(1.0 - cos_theta, 5.0), 0.60); - var water = mix(absorbed, sky, fresnel); + var water = mix(absorbed, refl, fresnel); // Foam on wave crests (slope proxy: low n.y), faded toward grazing // views — without the fade the whole far half of the river picks up @@ -174,14 +225,26 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let rim = (1.0 - shore_t) * water_params.knobs.y; water = mix(water, vec3(0.96, 0.99, 1.0), rim); - // Phase 7 — impulse ripples. - let imp_uv = clamp(in.world_pos.xz / 128.0 + vec2(0.5), vec2(0.0), vec2(0.999)); - let imp_dims = textureDimensions(impulse_tex); - let imp_ix = vec2(imp_uv * vec2(imp_dims)); - let imp = textureLoad(impulse_tex, imp_ix, 0).r; - let imp_mix = clamp(imp * 1.2, 0.0, 1.0); - water = mix(water, vec3(0.96, 0.99, 1.0), imp_mix * 0.85); - - let alpha = mix(0.45, 0.92, shore_t); + // Phase 7 / Round-3 — impulse wake foam. The normal agitation above + // carries most of the effect; this is just a soft churned-water + // brightening, capped well below white so it never reads as paint. + water = mix(water, vec3(0.88, 0.93, 0.95), imp * 0.22); + + // Round-3 — sun glint: tight Blinn specular from the sun direction. + // sample_env at any useful LOD blurs the sun disc into a wash, so a + // direct term is what actually sells the surface as water. Additive + // HDR, applied after all the mixes so foam / wake can't dim it. + let l = normalize(view.sun_dir.xyz); + let h = normalize(l + v); + let n_dot_h = max(dot(n, h), 0.0); + let glint = pow(n_dot_h, 380.0) * view.sun_dir.w; + water = water + view.sun_color.rgb * glint * 1.5; + + // Round-3 — the shader already composites the refracted scene colour + // itself, so hardware alpha-blending the same background in AGAIN + // just washes the surface out (the milky-film look). Own the pixel + // (alpha ≈ 1) and only fade alpha over the first 15 cm of water + // column so the shoreline still dissolves smoothly into the bank. + let alpha = 0.97 * shore_t; return vec4(water, alpha); } diff --git a/assets/models/house.glb b/assets/models/house.glb index 241ef67..6c946bb 100644 Binary files a/assets/models/house.glb and b/assets/models/house.glb differ diff --git a/assets/models/prop_tree.glb b/assets/models/prop_tree.glb index 8e96aef..581b9d9 100644 Binary files a/assets/models/prop_tree.glb and b/assets/models/prop_tree.glb differ diff --git a/assets/models/prop_tree2.glb b/assets/models/prop_tree2.glb new file mode 100644 index 0000000..4be4b27 Binary files /dev/null and b/assets/models/prop_tree2.glb differ diff --git a/assets/models/prop_tree3.glb b/assets/models/prop_tree3.glb new file mode 100644 index 0000000..99b0adf Binary files /dev/null and b/assets/models/prop_tree3.glb differ diff --git a/assets/sounds/SOURCES.md b/assets/sounds/SOURCES.md new file mode 100644 index 0000000..6cd6bfb --- /dev/null +++ b/assets/sounds/SOURCES.md @@ -0,0 +1,42 @@ +# Sound sources + +All files converted to 16-bit mono 44.1 kHz WAV (the engine's `parse_wav` +decodes 8/16-bit PCM only). Conversion: ffmpeg, no processing beyond +downmix/bit-depth (`tools` has no audio step — regenerate by hand if +sources change). + +## Unvanquished legacy assets (GPL-compatible, same line as our models) + +From github.com/UnvanquishedAssets/res-legacy_src.dpkdir (FLAC sources): + +| File(s) | Source path | +|---|---| +| `alien{0-4}_die{1-3}.wav` | `sound/player/level{0-4}/death{1-3}.flac` | +| `alien{0-4}_pain.wav` | `sound/player/level{0-4}/pain50_1.flac` | +| `alien0_attack.wav` | `models/weapons/level0/flash0.flac` | +| `alien1_attack.wav` | `sound/player/level1/grab.flac` | +| `alien{2-4}_attack.wav` | `models/weapons/level{2-4}/flash0.flac` | +| `impact_flesh.wav` | `models/weapons/level2/impactflesh0.flac` | +| `ricochet{1,2}.wav` | `models/weapons/rifle/ricochet{0,1}.flac` | +| `player_pain{1,2}.wav` | `sound/player/human_bsuit/pain{50,100}_1.flac` | +| `player_die{1,2}.wav` | `sound/player/human_bsuit/death{1,2}.flac` | + +Kind mapping: 0=dretch(level0) 1=mantis(level1) 2=marauder(level2) +3=dragoon(level3) 4=tyrant(level4). + +## Sonniss GDC 2024 Game Audio Bundle (royalty-free game-use license) + +License: royalty-free incl. commercial use, no attribution; NOT +redistributable as a sound library — only these processed/renamed +game-ready files are committed, never pack folders. + +| File | Source | +|---|---| +| `rifle_fire2.wav` | Dramatic Cat — SVD Dragunov, DESIGNED Single Shot Core Long | +| `blaster_fire.wav` | BluezoneCorp — Sci Fi Weapon, gun_shot_008 | +| `splash1.wav` | BluezoneCorp — Designed Water, impact_006 (first 1.2 s) | + +## Pre-existing (provenance predates this file) + +`rifle_fire.wav` (superseded by rifle_fire2 but kept), `dretch_attack.wav`, +`pickup.wav`, `menu.wav`, `game.wav`, `ambient.ogg`. diff --git a/assets/sounds/alien0_attack.wav b/assets/sounds/alien0_attack.wav new file mode 100644 index 0000000..df42619 Binary files /dev/null and b/assets/sounds/alien0_attack.wav differ diff --git a/assets/sounds/alien0_die1.wav b/assets/sounds/alien0_die1.wav new file mode 100644 index 0000000..039d364 Binary files /dev/null and b/assets/sounds/alien0_die1.wav differ diff --git a/assets/sounds/alien0_die2.wav b/assets/sounds/alien0_die2.wav new file mode 100644 index 0000000..27f47f7 Binary files /dev/null and b/assets/sounds/alien0_die2.wav differ diff --git a/assets/sounds/alien0_die3.wav b/assets/sounds/alien0_die3.wav new file mode 100644 index 0000000..049b0b6 Binary files /dev/null and b/assets/sounds/alien0_die3.wav differ diff --git a/assets/sounds/alien0_pain.wav b/assets/sounds/alien0_pain.wav new file mode 100644 index 0000000..518aab7 Binary files /dev/null and b/assets/sounds/alien0_pain.wav differ diff --git a/assets/sounds/alien1_attack.wav b/assets/sounds/alien1_attack.wav new file mode 100644 index 0000000..f4248f5 Binary files /dev/null and b/assets/sounds/alien1_attack.wav differ diff --git a/assets/sounds/alien1_die1.wav b/assets/sounds/alien1_die1.wav new file mode 100644 index 0000000..84cd957 Binary files /dev/null and b/assets/sounds/alien1_die1.wav differ diff --git a/assets/sounds/alien1_die2.wav b/assets/sounds/alien1_die2.wav new file mode 100644 index 0000000..06bab21 Binary files /dev/null and b/assets/sounds/alien1_die2.wav differ diff --git a/assets/sounds/alien1_die3.wav b/assets/sounds/alien1_die3.wav new file mode 100644 index 0000000..6f09a84 Binary files /dev/null and b/assets/sounds/alien1_die3.wav differ diff --git a/assets/sounds/alien1_pain.wav b/assets/sounds/alien1_pain.wav new file mode 100644 index 0000000..09dff98 Binary files /dev/null and b/assets/sounds/alien1_pain.wav differ diff --git a/assets/sounds/alien2_attack.wav b/assets/sounds/alien2_attack.wav new file mode 100644 index 0000000..01ee86b Binary files /dev/null and b/assets/sounds/alien2_attack.wav differ diff --git a/assets/sounds/alien2_die1.wav b/assets/sounds/alien2_die1.wav new file mode 100644 index 0000000..e668a1b Binary files /dev/null and b/assets/sounds/alien2_die1.wav differ diff --git a/assets/sounds/alien2_die2.wav b/assets/sounds/alien2_die2.wav new file mode 100644 index 0000000..f2c7094 Binary files /dev/null and b/assets/sounds/alien2_die2.wav differ diff --git a/assets/sounds/alien2_die3.wav b/assets/sounds/alien2_die3.wav new file mode 100644 index 0000000..393aaea Binary files /dev/null and b/assets/sounds/alien2_die3.wav differ diff --git a/assets/sounds/alien2_pain.wav b/assets/sounds/alien2_pain.wav new file mode 100644 index 0000000..94542c5 Binary files /dev/null and b/assets/sounds/alien2_pain.wav differ diff --git a/assets/sounds/alien3_attack.wav b/assets/sounds/alien3_attack.wav new file mode 100644 index 0000000..0ea0f8a Binary files /dev/null and b/assets/sounds/alien3_attack.wav differ diff --git a/assets/sounds/alien3_die1.wav b/assets/sounds/alien3_die1.wav new file mode 100644 index 0000000..3c93d32 Binary files /dev/null and b/assets/sounds/alien3_die1.wav differ diff --git a/assets/sounds/alien3_die2.wav b/assets/sounds/alien3_die2.wav new file mode 100644 index 0000000..1263b50 Binary files /dev/null and b/assets/sounds/alien3_die2.wav differ diff --git a/assets/sounds/alien3_die3.wav b/assets/sounds/alien3_die3.wav new file mode 100644 index 0000000..b115cf8 Binary files /dev/null and b/assets/sounds/alien3_die3.wav differ diff --git a/assets/sounds/alien3_pain.wav b/assets/sounds/alien3_pain.wav new file mode 100644 index 0000000..2f75b33 Binary files /dev/null and b/assets/sounds/alien3_pain.wav differ diff --git a/assets/sounds/alien4_attack.wav b/assets/sounds/alien4_attack.wav new file mode 100644 index 0000000..0ea0f8a Binary files /dev/null and b/assets/sounds/alien4_attack.wav differ diff --git a/assets/sounds/alien4_die1.wav b/assets/sounds/alien4_die1.wav new file mode 100644 index 0000000..37359bd Binary files /dev/null and b/assets/sounds/alien4_die1.wav differ diff --git a/assets/sounds/alien4_die2.wav b/assets/sounds/alien4_die2.wav new file mode 100644 index 0000000..3b22f99 Binary files /dev/null and b/assets/sounds/alien4_die2.wav differ diff --git a/assets/sounds/alien4_die3.wav b/assets/sounds/alien4_die3.wav new file mode 100644 index 0000000..c97fe3b Binary files /dev/null and b/assets/sounds/alien4_die3.wav differ diff --git a/assets/sounds/alien4_pain.wav b/assets/sounds/alien4_pain.wav new file mode 100644 index 0000000..3b3f3d3 Binary files /dev/null and b/assets/sounds/alien4_pain.wav differ diff --git a/assets/sounds/blaster_fire.wav b/assets/sounds/blaster_fire.wav new file mode 100644 index 0000000..d2cb998 Binary files /dev/null and b/assets/sounds/blaster_fire.wav differ diff --git a/assets/sounds/impact_flesh.wav b/assets/sounds/impact_flesh.wav new file mode 100644 index 0000000..74b1391 Binary files /dev/null and b/assets/sounds/impact_flesh.wav differ diff --git a/assets/sounds/player_die1.wav b/assets/sounds/player_die1.wav new file mode 100644 index 0000000..f996daf Binary files /dev/null and b/assets/sounds/player_die1.wav differ diff --git a/assets/sounds/player_die2.wav b/assets/sounds/player_die2.wav new file mode 100644 index 0000000..8ddba3c Binary files /dev/null and b/assets/sounds/player_die2.wav differ diff --git a/assets/sounds/player_pain1.wav b/assets/sounds/player_pain1.wav new file mode 100644 index 0000000..6810a4b Binary files /dev/null and b/assets/sounds/player_pain1.wav differ diff --git a/assets/sounds/player_pain2.wav b/assets/sounds/player_pain2.wav new file mode 100644 index 0000000..4c97e3a Binary files /dev/null and b/assets/sounds/player_pain2.wav differ diff --git a/assets/sounds/ricochet1.wav b/assets/sounds/ricochet1.wav new file mode 100644 index 0000000..e163935 Binary files /dev/null and b/assets/sounds/ricochet1.wav differ diff --git a/assets/sounds/ricochet2.wav b/assets/sounds/ricochet2.wav new file mode 100644 index 0000000..e17c87d Binary files /dev/null and b/assets/sounds/ricochet2.wav differ diff --git a/assets/sounds/rifle_fire2.wav b/assets/sounds/rifle_fire2.wav new file mode 100644 index 0000000..6175cfd Binary files /dev/null and b/assets/sounds/rifle_fire2.wav differ diff --git a/assets/sounds/splash1.wav b/assets/sounds/splash1.wav new file mode 100644 index 0000000..9ea34b2 Binary files /dev/null and b/assets/sounds/splash1.wav differ diff --git a/assets/textures/external/SOURCES.md b/assets/textures/external/SOURCES.md new file mode 100644 index 0000000..59a2181 --- /dev/null +++ b/assets/textures/external/SOURCES.md @@ -0,0 +1,23 @@ +# External texture sources + +All assets in this folder are **CC0 (public domain)** — no attribution +required, compatible with this repo's GPLv3 distribution. Provenance +recorded for hygiene: + +| Folder | Asset | Source | License | +|---|---|---|---| +| `pine_bark/` | Pine Bark (diff / nor_gl / rough, 1K + 2K JPG) | https://polyhaven.com/a/pine_bark | CC0 | +| `forrest_ground_01/` | Forest Ground 01 (diff / nor_gl / rough, 2K JPG) | https://polyhaven.com/a/forrest_ground_01 | CC0 | +| `leafset/LeafSet004/` | Leaf Set 004 (Color + Opacity, 2K PNG) | https://ambientcg.com/view?id=LeafSet004 | CC0 | + +Consumed by `tools/build-props.ts`: +- `pine_bark` 1K maps embed directly (as `image/jpeg`) into the tree GLB + trunk material. +- `LeafSet004` Color+Opacity are composited into the leaf-card cutout + texture (`externalLeafCard`) — real scanned leaves stamped into + branch-cluster cards. +- `forrest_ground_01` is downloaded for the upcoming terrain splat pass + (EN-014 texture arrays); not consumed yet. + +If this folder is missing, `build-props.ts` silently falls back to the +procedural textures — a fresh checkout still builds. diff --git a/assets/textures/external/forrest_ground_01/forrest_ground_01_diff_2k.jpg b/assets/textures/external/forrest_ground_01/forrest_ground_01_diff_2k.jpg new file mode 100644 index 0000000..1ca34d8 Binary files /dev/null and b/assets/textures/external/forrest_ground_01/forrest_ground_01_diff_2k.jpg differ diff --git a/assets/textures/external/forrest_ground_01/forrest_ground_01_nor_gl_2k.jpg b/assets/textures/external/forrest_ground_01/forrest_ground_01_nor_gl_2k.jpg new file mode 100644 index 0000000..c5bcc25 Binary files /dev/null and b/assets/textures/external/forrest_ground_01/forrest_ground_01_nor_gl_2k.jpg differ diff --git a/assets/textures/external/forrest_ground_01/forrest_ground_01_rough_2k.jpg b/assets/textures/external/forrest_ground_01/forrest_ground_01_rough_2k.jpg new file mode 100644 index 0000000..7af111f Binary files /dev/null and b/assets/textures/external/forrest_ground_01/forrest_ground_01_rough_2k.jpg differ diff --git a/assets/textures/external/leafset/LeafSet004/LeafSet004_2K-PNG_Color.png b/assets/textures/external/leafset/LeafSet004/LeafSet004_2K-PNG_Color.png new file mode 100644 index 0000000..c126f7c Binary files /dev/null and b/assets/textures/external/leafset/LeafSet004/LeafSet004_2K-PNG_Color.png differ diff --git a/assets/textures/external/leafset/LeafSet004/LeafSet004_2K-PNG_Opacity.png b/assets/textures/external/leafset/LeafSet004/LeafSet004_2K-PNG_Opacity.png new file mode 100644 index 0000000..e5362be Binary files /dev/null and b/assets/textures/external/leafset/LeafSet004/LeafSet004_2K-PNG_Opacity.png differ diff --git a/assets/textures/external/pine_bark/pine_bark_diff_1k.jpg b/assets/textures/external/pine_bark/pine_bark_diff_1k.jpg new file mode 100644 index 0000000..5c78b62 Binary files /dev/null and b/assets/textures/external/pine_bark/pine_bark_diff_1k.jpg differ diff --git a/assets/textures/external/pine_bark/pine_bark_diff_2k.jpg b/assets/textures/external/pine_bark/pine_bark_diff_2k.jpg new file mode 100644 index 0000000..85fd110 Binary files /dev/null and b/assets/textures/external/pine_bark/pine_bark_diff_2k.jpg differ diff --git a/assets/textures/external/pine_bark/pine_bark_nor_gl_1k.jpg b/assets/textures/external/pine_bark/pine_bark_nor_gl_1k.jpg new file mode 100644 index 0000000..a63c6ed Binary files /dev/null and b/assets/textures/external/pine_bark/pine_bark_nor_gl_1k.jpg differ diff --git a/assets/textures/external/pine_bark/pine_bark_nor_gl_2k.jpg b/assets/textures/external/pine_bark/pine_bark_nor_gl_2k.jpg new file mode 100644 index 0000000..1984bbc Binary files /dev/null and b/assets/textures/external/pine_bark/pine_bark_nor_gl_2k.jpg differ diff --git a/assets/textures/external/pine_bark/pine_bark_rough_1k.jpg b/assets/textures/external/pine_bark/pine_bark_rough_1k.jpg new file mode 100644 index 0000000..77ff03f Binary files /dev/null and b/assets/textures/external/pine_bark/pine_bark_rough_1k.jpg differ diff --git a/assets/textures/external/pine_bark/pine_bark_rough_2k.jpg b/assets/textures/external/pine_bark/pine_bark_rough_2k.jpg new file mode 100644 index 0000000..5c44c50 Binary files /dev/null and b/assets/textures/external/pine_bark/pine_bark_rough_2k.jpg differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_ambientocclusion-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_ambientocclusion-2K.png new file mode 100644 index 0000000..0770a57 Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_ambientocclusion-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_basecolor-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_basecolor-2K.png new file mode 100644 index 0000000..b4b6f1e Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_basecolor-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_height-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_height-2K.png new file mode 100644 index 0000000..aab4750 Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_height-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_metallic-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_metallic-2K.png new file mode 100644 index 0000000..a470416 Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_metallic-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_normal-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_normal-2K.png new file mode 100644 index 0000000..b287ba4 Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_normal-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_opacity-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_opacity-2K.png new file mode 100644 index 0000000..5006f2e Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_opacity-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_roughness-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_roughness-2K.png new file mode 100644 index 0000000..a7bc613 Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_roughness-2K.png differ diff --git a/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_translucency-2K.png b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_translucency-2K.png new file mode 100644 index 0000000..f41e582 Binary files /dev/null and b/assets/textures/external/platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_translucency-2K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_ambientocclusion-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_ambientocclusion-4K.png new file mode 100644 index 0000000..9f077ce Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_ambientocclusion-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_basecolor-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_basecolor-4K.png new file mode 100644 index 0000000..753d485 Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_basecolor-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_height-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_height-4K.png new file mode 100644 index 0000000..abd4328 Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_height-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_metallic-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_metallic-4K.png new file mode 100644 index 0000000..88c745a Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_metallic-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_normal-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_normal-4K.png new file mode 100644 index 0000000..c288fce Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_normal-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_opaciy-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_opaciy-4K.png new file mode 100644 index 0000000..7ad68f0 Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_opaciy-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_roughness-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_roughness-4K.png new file mode 100644 index 0000000..7252c7f Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_roughness-4K.png differ diff --git a/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_translucency-4K.png b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_translucency-4K.png new file mode 100644 index 0000000..6b4a924 Binary files /dev/null and b/assets/textures/external/robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_translucency-4K.png differ diff --git a/src/main.ts b/src/main.ts index eedfee0..5ee41bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,15 +8,16 @@ import { vec3, isKeyPressed, Key, Vec3, injectKeyDown, injectKeyUp, isAnyInputPressed, disableCursor, enableCursor, takeScreenshot, - loadModel, drawModel, loadModelAnimation, updateModelAnimation, + loadModel, drawModel, drawModelRotated, getModelBounds, loadModelAnimation, updateModelAnimation, createMesh, createMeshExplicit, genMeshCube, compileMaterial, compileRefractiveMaterial, drawMeshWithMaterial, compileMaterialInstanced, createInstanceBuffer, drawMeshWithMaterialInstanced, - initAudio, loadSound, playSound, setSoundVolume, + initAudio, loadSound, playSound, setSoundVolume, playSound3D, setListenerPosition, loadMusic, playMusic, stopMusic, updateMusicStream, setMusicVolume, setProfilerEnabled, getProfilerOverlay, getProfilerFrameHistory, splatImpulse, setMaterialParams, compileMaterialFromFile, loadMaterial, + createPlanarReflection, setMaterialReflectionProbe, } from 'bloom'; import { setVignette, setFilmGrain, @@ -57,6 +58,49 @@ const sfxPickup = loadSound('assets/sounds/pickup.wav'); setSoundVolume(sfxFire, 0.35); setSoundVolume(sfxAttack, 0.6); setSoundVolume(sfxPickup, 0.8); +// Round-7 audio (see assets/sounds/SOURCES.md): Sonniss weapon/water +// shots + the aliens' ORIGINAL Unvanquished vocals (same GPL asset +// line as the models). Alien deaths/attacks/pain play positionally via +// playSound3D; the listener follows the camera every frame. +const sfxFireRifle = loadSound('assets/sounds/rifle_fire2.wav'); +const sfxFireBlaster = loadSound('assets/sounds/blaster_fire.wav'); +setSoundVolume(sfxFireRifle, 0.40); +setSoundVolume(sfxFireBlaster, 0.45); +const sfxImpactFlesh = loadSound('assets/sounds/impact_flesh.wav'); +setSoundVolume(sfxImpactFlesh, 0.45); +const sfxRicochet = [loadSound('assets/sounds/ricochet1.wav'), + loadSound('assets/sounds/ricochet2.wav')]; +setSoundVolume(sfxRicochet[0], 0.25); +setSoundVolume(sfxRicochet[1], 0.25); +const sfxSplash = loadSound('assets/sounds/splash1.wav'); +setSoundVolume(sfxSplash, 0.30); +const sfxPlayerPain = [loadSound('assets/sounds/player_pain1.wav'), + loadSound('assets/sounds/player_pain2.wav')]; +setSoundVolume(sfxPlayerPain[0], 0.65); +setSoundVolume(sfxPlayerPain[1], 0.65); +const sfxPlayerDie = [loadSound('assets/sounds/player_die1.wav'), + loadSound('assets/sounds/player_die2.wav')]; +setSoundVolume(sfxPlayerDie[0], 0.8); +setSoundVolume(sfxPlayerDie[1], 0.8); +// Per-kind alien vocals, kind order matches KIND_NAME (0=dretch .. +// 4=tyrant = Unvanquished level0..level4). Flat arrays, index +// kind*3+variant for deaths (Perry convention). +const sfxAlienDie: any[] = new Array(15); +const sfxAlienAttack: any[] = new Array(5); +const sfxAlienPain: any[] = new Array(5); +for (let k = 0; k < 5; k++) { + for (let v = 0; v < 3; v++) { + const s = loadSound('assets/sounds/alien' + k + '_die' + (v + 1) + '.wav'); + setSoundVolume(s, 0.75); + sfxAlienDie[k * 3 + v] = s; + } + const a = loadSound('assets/sounds/alien' + k + '_attack.wav'); + setSoundVolume(a, 0.55); + sfxAlienAttack[k] = a; + const p = loadSound('assets/sounds/alien' + k + '_pain.wav'); + setSoundVolume(p, 0.40); + sfxAlienPain[k] = p; +} // Two tracks from the 2026-07-03 asset drop: menu.wav on the title screen, // game.wav once play starts (see the gameState transition in the loop). // The old ambient.ogg loop stays in the repo as a fallback. @@ -197,47 +241,37 @@ const meshModelHandles = new Array(W.UNIQUE_MODEL_COUNT); for (let i = 0; i < W.UNIQUE_MODEL_COUNT; i++) { meshModelHandles[i] = W.MODEL_IS_BOX[i] === 1 ? 0 : loadModel(W.UNIQUE_MODELS[i]); } -// Tier 3b — replace the cardboard-cutout prop_tree.glb with four -// real CC0 variants (oak, fat, detailed, default) drawn in -// rotation. Picked per-mesh by index hash so the same world -// always lays out the same trees, but adjacent trees never -// match exactly. Scale jitter via the same hash adds height -// variety. See docs/visual-quality.md. -const treeVariants: number[] = [ - loadModel('assets/models/tree_oak.glb'), - loadModel('assets/models/tree_fat.glb'), - loadModel('assets/models/tree_detailed.glb'), - loadModel('assets/models/tree_default.glb'), -]; - -// Tree wind-sway material. Vertex displaces proportional to local -// y² (canopy moves more than trunk), per-tree phase derived from -// world XZ origin so neighbours desync. Loaded via the file API -// for hot-reload + drawn through drawMeshWithMaterial across both -// primitives of the tree GLBs. -const matTree = compileMaterialFromFile('assets/materials/tree.wgsl', 'opaque'); -const TREE_PARAMS = [ - // wind dir.xz, max sway (m at canopy), wind frequency (rad/s) - 0.85, 0.50, 0.18, 1.4, - // trunk colour rgb, trunk_top_y (model-space height below which - // a vertex reads as trunk). Kenney tree trunks top out around - // y ≈ 0.6 in model space. - 0.30, 0.20, 0.12, 0.65, +// Round-4 (de-cartoonification) — the Kenney low-poly gumdrops were the +// single biggest "toy world" signal: flat-shaded solid-colour polyhedra. +// Back to the PUBG-style leaf-card trees (bark-textured tapered trunk + +// alpha-cutout leaf-card canopy). Drawn through the cached-model scene +// shader, which gives them wind sway, backlit leaf transmission and +// dappled cutout shadows for free — and they show up in the water's +// planar reflection (cached models render into the probe). Three GLB +// variants (normal / tall-narrow / short-wide) from build-props.ts. +const treeVariants = [ + loadModel('assets/models/prop_tree.glb'), + loadModel('assets/models/prop_tree2.glb'), + loadModel('assets/models/prop_tree3.glb'), ]; -if (matTree > 0) setMaterialParams(matTree, TREE_PARAMS); -// Kenney trees mix 2 or 3 primitives — read meshCount per-variant. -const TREE_MESH_COUNTS: number[] = [2, 2, 3, 2]; // oak, fat, detailed, default +// All tree GLBs are 4 primitives: trunk + 2 branch stubs + leaf cards. +const TREE_GLB_PARTS = 4; -// Forest scatter — pre-place ~120 trees across the open field at +// Forest scatter — pre-placed trees across the open field at // startup using deterministic LCG. Each gets a variant, scale // jitter, position jitter, and a subtle per-tree hue tint so the // forest doesn't read as "the same model copy-pasted." Trees go // in a flat array so the per-frame draw loop is a single pass. -const FOREST_COUNT_MAX = 120; +// Round-5: 120 → 88. Through the cached scene pipeline every tree +// now pays shadow-cascade + water-probe + main-pass draws (the old +// immediate path skipped all of those — and alpha cutout with them), +// so a leaner count buys back most of the frame-rate cost. +const FOREST_COUNT_MAX = 88; const FOREST_X = new Array(FOREST_COUNT_MAX); const FOREST_Y = new Array(FOREST_COUNT_MAX); const FOREST_Z = new Array(FOREST_COUNT_MAX); const FOREST_VAR = new Array(FOREST_COUNT_MAX); +const FOREST_YAW = new Array(FOREST_COUNT_MAX); const FOREST_SCALE = new Array(FOREST_COUNT_MAX); const FOREST_TINT_R = new Array(FOREST_COUNT_MAX); const FOREST_TINT_G = new Array(FOREST_COUNT_MAX); @@ -280,8 +314,11 @@ let FOREST_COUNT = 0; FOREST_X[FOREST_COUNT] = px; FOREST_Y[FOREST_COUNT] = py; FOREST_Z[FOREST_COUNT] = pz; - FOREST_VAR[FOREST_COUNT] = Math.floor(r3 * 4) & 3; - FOREST_SCALE[FOREST_COUNT] = 2.5 * (0.78 + r4 * 0.45); // 1.95 .. 3.06 + FOREST_VAR[FOREST_COUNT] = Math.floor(r3 * 3) % 3; + FOREST_YAW[FOREST_COUNT] = r3 * 360; // degrees (drawModelRotated) + // Leaf-card tree is ~5.5 m tall at scale 1 — 0.85..1.45 gives a + // believable size hierarchy (4.7..8 m) instead of uniform bushes. + FOREST_SCALE[FOREST_COUNT] = 0.85 + r4 * 0.60; // Per-tree hue tint — slight greens vary canopy, drier on the // sunny side. Shifts ±10% around white. const hueShift = (r5 - 0.5) * 0.20; @@ -306,11 +343,17 @@ for (let i = 0; i < W.UNIQUE_MODEL_COUNT; i++) { // noise_freq, slope_threshold, ridge_height, pale_strength const matTerrain = compileMaterialFromFile('assets/materials/terrain.wgsl', 'opaque'); const TERRAIN_PARAMS = [ - 0.55, 0.62, 0.30, 0.0, - 0.20, 0.46, 0.16, 0.0, - 0.10, 0.28, 0.08, 0.0, - 0.34, 0.26, 0.18, 0.0, + // Round-4 palette — desaturated toward olive; the old stops were + // saturated toy-greens and read as a plastic lawn. + 0.46, 0.46, 0.26, 0.0, + 0.24, 0.34, 0.16, 0.0, + 0.13, 0.21, 0.10, 0.0, + 0.32, 0.25, 0.17, 0.0, 0.18, 0.78, 4.0, 0.55, + // river: centre z, half-width (full mud), bank fade width, waterline y. + // Matches the arena_02 river volume (z=12, carve half-width 2.6) — + // defined here because WATER_* constants load later in this file. + 12.0, 2.4, 1.8, 0.12, ]; if (matTerrain > 0) setMaterialParams(matTerrain, TERRAIN_PARAMS); // Per-mesh collider from userData.collider === 'box'. @@ -587,11 +630,16 @@ const matWaterFromFile = compileMaterialFromFile('assets/materials/water.wgsl', const matWater = matWaterFromFile; // Tier 4 layout: absorption coefficient (red dies fastest, blue // slowest), deep-water colour (greenish-teal), then knobs: -// foam, rim, sky_lod, micro_normal_strength. +// foam, rim, sky_lod, micro_strength. +// Round-3 recalibration: the river bed sits only ~0.3 m down, so the +// old 0.55/m absorption left the water reading as hazy grass — +// exaggerate it (games do) so a shallow column still shifts teal. +// Rim 0.25 → 0.10 and sky_lod 2.0 → 0.6 both fight the milky wash: +// less white shoreline paint, sharper sky/cloud reflection. const WATER_PARAMS = [ - 0.55, 0.10, 0.05, 0.0, // absorption per metre + 2.20, 0.90, 0.60, 0.0, // absorption per metre 0.05, 0.18, 0.28, 0.0, // deep_tint - 0.60, 0.25, 2.0, 0.18, // foam / rim / sky_lod / micro_strength + 0.50, 0.10, 0.6, 0.18, // foam / rim / sky_lod / micro_strength ]; if (matWater > 0) setMaterialParams(matWater, WATER_PARAMS); @@ -646,6 +694,14 @@ const WATER_INDS = new Array(_wic); } const matWaterMesh = createMeshExplicit(WATER_VERTS, _wvc, WATER_INDS, _wic); +// Round-3 — planar reflection probe (EN-011). Mirror-renders the +// cached-model world across the water plane into an HDR RT each frame; +// water.wgsl blends it over the analytic sky by probe alpha, so trees / +// house / banks actually appear in the river. Materials linked to a +// probe are excluded from their own reflection automatically. +const waterProbe = matWater > 0 ? createPlanarReflection(WATER_Y, 0, 1, 0, 512) : 0; +if (waterProbe > 0) setMaterialReflectionProbe(matWater, waterProbe); + // ---- SH-021 instanced grass — canonical blade × N instances ------------- // Replaces the Tier-2b 5 000-blade baked-mesh path. One canonical // 6-vert cross-quad blade is uploaded once; per-frame draw is a @@ -748,7 +804,10 @@ const GRASS_INSTANCED_WGSL = ' let cloud = cloud_shadow(in.world_pos.xz, frame.time);\n' + ' let shadow = sample_sun_shadow(in.world_pos);\n' + ' let direct = view.sun_color.rgb * direct_w * cloud * shadow;\n' + - ' let albedo = in.blade_tint * (0.7 + 0.3 * in.tip_weight);\n' + + ' let tip2 = in.tip_weight * in.tip_weight;\n' + + ' let root_col = in.blade_tint * vec3(0.42, 0.50, 0.38);\n' + + ' let tip_col = in.blade_tint * vec3(1.12, 1.08, 0.72);\n' + + ' let albedo = mix(root_col, tip_col, tip2);\n' + ' let trans_color = albedo * vec3(1.10, 1.20, 0.85) * grass.base.w;\n' + // Sky-fill: HDR irradiance sampled straight up (thin blades respond to // the sky dome; a fixed direction avoids per-blade ambient flicker from @@ -765,34 +824,68 @@ const GRASS_INSTANCED_WGSL = '}\n'; const matGrass = compileMaterialInstanced(GRASS_INSTANCED_WGSL); const GRASS_PARAMS = [ - // base hue rgb, transmission strength - 0.30, 0.46, 0.18, 0.40, + // base hue rgb (Round-4: slightly desaturated), transmission strength + 0.30, 0.42, 0.20, 0.40, ]; if (matGrass > 0) setMaterialParams(matGrass, GRASS_PARAMS); -// Canonical blade mesh — 6 verts × 12 floats (pos.3 normal.3 -// color.4 uv.2), 12 indices = 4 triangles (front + back of each -// plane in the cross). color.r is the tip weight (0 at root, 1 at -// tip) which the vertex shader uses to localise the wind sway. -const GRASS_BLADE_W = 0.06; -const GRASS_BLADE_H = 0.45; +// Canonical blade mesh — Round-4: two-segment tapered blades with a +// bow, instead of the old single hard triangle (which read as plastic +// spikes). Per crossed plane: 2 root verts → 2 narrower mid verts → +// 1 tip vert, bowing along the plane normal so the per-instance yaw +// randomises bow direction across the field. 10 verts × 12 floats +// (pos.3 normal.3 color.4 uv.2); 36 indices = 12 triangles (front + +// back of 3 quads/tips per plane). color.r is the tip weight (0 at +// root → 1 at tip) which the vertex shader uses for wind sway and +// the fragment shader for the root→tip colour gradient. +const GB_W0 = 0.045; // root half-width +const GB_W1 = 0.026; // mid half-width +const GB_H1 = 0.26; // mid height +const GB_H2 = 0.50; // tip height +const GB_B1 = 0.025; // bow at mid +const GB_B2 = 0.075; // bow at tip const GRASS_BLADE_VERTS: number[] = [ - // Plane 1 (XY plane, normal +Z) - -GRASS_BLADE_W, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, - GRASS_BLADE_W, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, - 0, GRASS_BLADE_H, 0, 0, 0, 1, 1, 0, 1, 1, 0.5, 1, - // Plane 2 (YZ plane, normal +X) - 0, 0, -GRASS_BLADE_W, 1, 0, 0, 0, 0, 1, 1, 0, 0, - 0, 0, GRASS_BLADE_W, 1, 0, 0, 0, 0, 1, 1, 1, 0, - 0, GRASS_BLADE_H, 0, 1, 0, 0, 1, 0, 1, 1, 0.5, 1, + // Plane 1 (XY plane, normal +Z, bows toward +Z) + -GB_W0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, + GB_W0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, + -GB_W1, GB_H1, GB_B1, 0, 0, 1, 0.55, 0, 1, 1, 0, 0.55, + GB_W1, GB_H1, GB_B1, 0, 0, 1, 0.55, 0, 1, 1, 1, 0.55, + 0, GB_H2, GB_B2, 0, 0, 1, 1, 0, 1, 1, 0.5, 1, + // Plane 2 (YZ plane, normal +X, bows toward +X) + 0, 0, -GB_W0, 1, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, GB_W0, 1, 0, 0, 0, 0, 1, 1, 1, 0, + GB_B1, GB_H1, -GB_W1, 1, 0, 0, 0.55, 0, 1, 1, 0, 0.55, + GB_B1, GB_H1, GB_W1, 1, 0, 0, 0.55, 0, 1, 1, 1, 0.55, + GB_B2, GB_H2, 0, 1, 0, 0, 1, 0, 1, 1, 0.5, 1, ]; const GRASS_BLADE_INDS: number[] = [ - // Plane 1 front (CCW from +Z) + back (CCW from -Z) - 0, 1, 2, 0, 2, 1, - // Plane 2 front (CCW from +X) + back (CCW from -X) - 3, 4, 5, 3, 5, 4, + // Plane 1: root quad + tip tri, front (CCW from +Z) then back. + 0, 1, 3, 0, 3, 2, 2, 3, 4, + 0, 3, 1, 0, 2, 3, 2, 4, 3, + // Plane 2: same topology at base 5. + 5, 6, 8, 5, 8, 7, 7, 8, 9, + 5, 8, 6, 5, 7, 8, 7, 9, 8, ]; -const matGrassMesh = createMeshExplicit(GRASS_BLADE_VERTS, 6, GRASS_BLADE_INDS, 12); +const matGrassMesh = createMeshExplicit(GRASS_BLADE_VERTS, 10, GRASS_BLADE_INDS, 36); + +// Round-4 — deterministic value noise over world XZ, used for the +// large-scale "moisture" patches that vary grass colour/height (and +// loosely match the terrain shader's macro patches). Pure math, no +// state — Perry-safe. +function hashCell(ix: number, iz: number): number { + let h = (ix * 374761393 + iz * 668265263) | 0; + h = (h ^ (h >> 13)) | 0; + h = (h * 1274126177) | 0; + return ((h ^ (h >> 16)) >>> 0) / 4294967295; +} +function moistureNoise(x: number, z: number): number { + const fx = Math.floor(x), fz = Math.floor(z); + const tx = x - fx, tz = z - fz; + const sx = tx * tx * (3 - 2 * tx), sz = tz * tz * (3 - 2 * tz); + const a = hashCell(fx, fz), b = hashCell(fx + 1, fz); + const c = hashCell(fx, fz + 1), d = hashCell(fx + 1, fz + 1); + return (a * (1 - sx) + b * sx) * (1 - sz) + (c * (1 - sx) + d * sx) * sz; +} // Per-instance buffer — 20 000 blades × 9 floats (pos.xyz, rot_y, // scale, tint.rgba). Same RNG / heightmap / rejection logic as the @@ -818,8 +911,16 @@ let GRASS_INSTANCE_COUNT = 0; const r4 = seed / 0x7fffffff; seed = ((seed * 1103515245) + 12345) & 0x7fffffff; const r5 = seed / 0x7fffffff; - const px = -38 + r1 * 76; - const pz = -38 + r2 * 76; + let px = -38 + r1 * 76; + let pz = -38 + r2 * 76; + // Round-4 — clumping: pull each blade 60% toward a per-1.7 m-cell + // anchor so the field reads as natural tufts instead of an even + // lawn. Pull FIRST, then reject on the pulled position. + const cellX = Math.floor(px / 1.7), cellZ = Math.floor(pz / 1.7); + const ax = (cellX + 0.2 + hashCell(cellX, cellZ) * 0.6) * 1.7; + const az = (cellZ + 0.2 + hashCell(cellZ, cellX) * 0.6) * 1.7; + px = px + (ax - px) * 0.6; + pz = pz + (az - pz) * 0.6; if (px > BX0 && px < BX1 && pz > BZ0 && pz < BZ1) continue; if (Math.abs(pz - 12) < 3.5 && Math.abs(px) < 40) continue; // Bilinear heightmap sample. @@ -836,17 +937,20 @@ let GRASS_INSTANCE_COUNT = 0; py = (h00 * (1 - fx) + h10 * fx) * (1 - fz) + (h01 * (1 - fx) + h11 * fx) * fz; } + // Round-4 — moisture patches (~12 m wavelength): low-moisture areas + // go dry olive-yellow and slightly shorter, lush areas stay deep + // green and tall. Plus per-blade jitter on top. + const moist = moistureNoise(px * 0.085, pz * 0.085); + const dry = Math.max(0, Math.min(1, (0.55 - moist) * 3.0)); + const jit = (r5 - 0.5) * 0.16; GRASS_INSTANCES[wi++] = px; GRASS_INSTANCES[wi++] = py; GRASS_INSTANCES[wi++] = pz; - GRASS_INSTANCES[wi++] = r3 * 6.2832; // rot_y radians - GRASS_INSTANCES[wi++] = 0.85 + r4 * 0.40; // scale 0.85..1.25 - // Per-blade tint multiplier — biased toward green, ±15% per - // channel so the field reads as natural variation rather than - // a single colour. - GRASS_INSTANCES[wi++] = 0.85 + r5 * 0.30; - GRASS_INSTANCES[wi++] = 0.95 + r4 * 0.10; - GRASS_INSTANCES[wi++] = 0.85 + r3 * 0.30; + GRASS_INSTANCES[wi++] = r3 * 6.2832; // rot_y radians + GRASS_INSTANCES[wi++] = (0.85 + r4 * 0.40) * (1.05 - dry * 0.30); // scale + GRASS_INSTANCES[wi++] = 0.85 + dry * 0.60 + jit; // tint r + GRASS_INSTANCES[wi++] = 1.03 + dry * 0.02 + jit * 0.5; // tint g + GRASS_INSTANCES[wi++] = 0.95 - dry * 0.40 + jit * 0.3; // tint b GRASS_INSTANCES[wi++] = 1.0; GRASS_INSTANCE_COUNT++; } @@ -1092,7 +1196,13 @@ const WALK_TILT = 0.06; // unused; kept for future side-sway const ATTACK_LUNGE_AMP = 0.25; // m forward during attack // Per-kind tuning. Collider half-extents are generous (taller than the visual // model) so horizontal aim at any range connects. -const KIND_SCALE = [1.6, 1.6, 1.9, 2.4, 3.0]; +// Round-6 rescale — the GLBs have wildly different native sizes (engine +// getModelBounds rest-pose heights: dretch 0.74, mantis 2.54, marauder +// 4.63, dragoon 14.99, tyrant 5.27 m), and the old uniform-ish scales +// made the later kinds monstrous (dragoon 36 m!). Scales now target +// world heights ≈ 0.65 / 1.4 / 1.8 / 2.1 / 3.0 m. Colliders unchanged +// (they were deliberately generous). +const KIND_SCALE = [0.88, 0.55, 0.39, 0.14, 0.57]; const KIND_HX = [1.0, 0.9, 1.2, 1.4, 1.8]; const KIND_HY = [1.0, 1.0, 1.2, 1.5, 2.0]; const KIND_HZ = [1.1, 1.0, 1.3, 1.6, 2.0]; @@ -1258,6 +1368,14 @@ let gameState = 0; const MUZZLE_FLASH_DUR = 0.08; let muzzleFlashT = 0; let damageFlashT = 0; +// Phase 7 / Round-3 — seconds until the next wading splat may fire. +// Splatting every moving frame overwhelmed the field's 3.2%/frame decay +// (steady state ~19× over max — a stuck white smear); one splat per +// 0.15 s at lower strength holds it near 1.0 instead. +let splatCooldown = 0; +// Round-7 — wading splash SFX cadence (slower than the visual splats +// or it reads as a drum loop). +let splashSoundCD = 0; let shotsFired = 0; let shotsHit = 0; @@ -1409,11 +1527,11 @@ setFilmGrain(0.018); // barely-there noise — 0.05+ reads as heavy speckl } // Forest trees — every primitive of every placed tree. glTF materials // ride along through attachModelToNode, so trunks bounce brown and - // canopies green without per-node colour overrides. + // canopies green without per-node colour overrides. (GI proxies are + // unrotated — close enough for bounce lighting.) for (let i = 0; i < FOREST_COUNT; i++) { const v = treeVariants[FOREST_VAR[i]]; - const meshCount = TREE_MESH_COUNTS[FOREST_VAR[i]]; - for (let mIdx = 0; mIdx < meshCount; mIdx++) { + for (let mIdx = 0; mIdx < TREE_GLB_PARTS; mIdx++) { const n = createSceneNode(); attachModelToNode(n, (v as any).handle, mIdx); setSceneNodeTrs(n, FOREST_X[i], FOREST_Y[i], FOREST_Z[i], 0, FOREST_SCALE[i]); @@ -1431,6 +1549,19 @@ setFilmGrain(0.018); // barely-there noise — 0.05+ reads as heavy speckl const SELFTEST = false; let testFrame = 0; +// ---- WATERTEST harness (temporary diagnostic) ------------------------------- +// Auto-starts a run, holds the camera on the river (the yaw the river spans +// along) and wades the player up/down the band at spawn so an external +// capture script can verify the water look + footstep wake without real +// input. Wading stops after 20 s of uptime so wake decay can be captured +// too. Same dormancy contract as SELFTEST/PERFTEST: MUST be false in +// shipped builds. +const WATERTEST = false; +// Wall-clock anchor for the scripted walk — getTime() at frame 20 already +// includes several seconds of asset loading, so timings are relative to +// the moment the harness starts the run. +let waterTestT0 = -1; + // ---- PERFTEST harness (temporary diagnostic) -------------------------------- // Bisects the fullscreen slowdown: measures wall-clock FPS over 120-frame // windows on the title screen (full world renders as the backdrop), toggling @@ -1705,6 +1836,52 @@ while (!windowShouldClose()) { // walk direction. Runs before the player controller update so // the override actually reaches updatePlayerController. if (SELFTEST && testFrame >= 20) input.moveZ = -1; + // Watertest: start the run, aim down the river, wade back and forth + // along it (direction swaps every ~1.2 s; the river spans X so the + // camera-forward walk stays inside the band). See harness block above. + if (WATERTEST) { + if (testFrame === 20 && gameState === 0) { + gameState = 1; + stopMusic(musicMenu); + playMusic(musicAmbient); + } + // Round-6 verification: waves ENABLED so enemy size/facing/shadows can + // be judged in the captures. (Re-suppress with waveBreakTimer = 9999 + // when a run needs an unshoved scripted walk.) + if (testFrame === 30) { + for (let k = 0; k < 5; k++) { + const bb = getModelBounds(mdlAliens[k]); + console.log('BOUNDS ' + KIND_NAME[k] + + ' h=' + (bb.max.y - bb.min.y).toFixed(2) + + ' w=' + (bb.max.x - bb.min.x).toFixed(2) + + ' d=' + (bb.max.z - bb.min.z).toFixed(2) + + ' scaled_h=' + ((bb.max.y - bb.min.y) * KIND_SCALE[k]).toFixed(2)); + } + } + playerHP = PLAYER_HP_MAX; + gameOver = false; + // Face -Z: spawn is (0, 20), the river band is z 9.5..14.5, so the + // river lies dead ahead and the camera looks across it at the far + // bank. moveZ = -1 is forward (same convention as SELFTEST). + CAM[0] = 0; + CAM[1] = 0.42; + input.moveX = 0; + input.moveZ = 0; + if (gameState === 1) { + if (waterTestT0 < 0) waterTestT0 = getTime(); + const tw = getTime() - waterTestT0; + // Shadow check: stay ON GRASS at spawn (the water shader receives + // no sun shadow, so a wading player can't show one). Restore the + // walk below for water-look captures. + // if (tw < 1.3) { + // input.moveZ = -1; // walk into the river (~8 m) + // } else if (tw < 20) { + // input.moveX = Math.sin(tw * 2.6) > 0 ? 1 : -1; // strafe along the band + // } + if (tw < 0) { input.moveZ = 0; } // keep tw referenced + if ((testFrame % 120) === 0) console.log('WATERTEST fps=' + getFPS()); + } + } // Only apply mouse look when cursor is captured — avoids jumpy yaw/pitch // when the user is moving the mouse outside the window. The first ~10 // frames after window creation often report giant mouse deltas (system @@ -1747,8 +1924,15 @@ while (!windowShouldClose()) { pp.z < WATER_CZ + WATER_D * 0.5 && Math.abs(pp.x) < WATER_W * 0.5; const moving = Math.abs(input.moveX) + Math.abs(input.moveZ) > 0.1; - if (inRiver && moving) { - splatImpulse(pp.x, pp.z, 1.2, 0.6); + splatCooldown -= dt; + if (inRiver && moving && splatCooldown <= 0) { + splatImpulse(pp.x, pp.z, 1.0, 0.4); + splatCooldown = 0.15; + } + splashSoundCD -= dt; + if (inRiver && moving && splashSoundCD <= 0) { + playSound(sfxSplash); + splashSoundCD = 0.55; } } @@ -1833,8 +2017,16 @@ while (!windowShouldClose()) { playerHP = playerHP - KIND_DMG[k]; damageFlashT = 0.5; enAttackCD[i] = KIND_CD[k]; - playSound(sfxAttack); - if (playerHP <= 0) { playerHP = 0; gameOver = true; } + // Per-kind bite/claw at the attacker's position + an armored + // pain grunt (alternating variants; heavy one when it kills). + playSound3D(sfxAlienAttack[k], enX[i], enY[i] + 1, enZ[i]); + if (playerHP <= 0) { + playerHP = 0; + if (!gameOver) playSound(sfxPlayerDie[i & 1]); + gameOver = true; + } else { + playSound(sfxPlayerPain[i & 1]); + } } if (enAttackCD[i] > 0) enAttackCD[i] = enAttackCD[i] - dt; if (enFlashT[i] > 0) enFlashT[i] = enFlashT[i] - dt; @@ -1914,7 +2106,7 @@ while (!windowShouldClose()) { if (fireIntent || (forceFire && haveAmmo && combatActive)) { shotsFired = shotsFired + 1; muzzleFlashT = MUZZLE_FLASH_DUR; - playSound(sfxFire); + playSound(isRifle ? sfxFireRifle : sfxFireBlaster); // Third-person aiming: the crosshair is at screen centre, so // trace the camera-forward line out to a far point, treat that // as the aim target, and fire from the player's shoulder toward @@ -1949,10 +2141,13 @@ while (!windowShouldClose()) { if (hit) { shotsHit = shotsHit + 1; spawnSpark(hit.point); + let struckEnemy = false; for (let i = 0; i < MAX_ENEMIES; i++) { if (enAlive[i] > 0 && hit.body === enBody[i]) { + struckEnemy = true; enHP[i] = enHP[i] - RIFLE_DAMAGE; enFlashT[i] = DRETCH_HIT_FLASH; + playSound3D(sfxImpactFlesh, hit.point.x, hit.point.y, hit.point.z); if (enHP[i] <= 0) { // Death: AI/waves see it gone (enAlive 0), the physics body // leaves play, but the corpse keeps drawing at its last @@ -1964,11 +2159,21 @@ while (!windowShouldClose()) { const pk = playerPosition(); enDeathYaw[i] = Math.atan2(pk.x - enX[i], -(pk.z - enZ[i])); setBodyPosition(enBody[i], vec3(enX[i], -100, enZ[i]), false); - playSound(sfxAttack); // reuse clank as death thud + // Per-kind death screech, positional, variant by slot. + playSound3D(sfxAlienDie[enKind[i] * 3 + (i % 3)], + enX[i], enY[i] + 1, enZ[i]); + } else if ((i & 3) === 0) { + // Occasional pain bark so sustained fire isn't monotone. + playSound3D(sfxAlienPain[enKind[i]], enX[i], enY[i] + 1, enZ[i]); } break; } } + if (!struckEnemy) { + // World hit — ricochet ping (alternating variants). + playSound3D(sfxRicochet[shotsFired & 1], + hit.point.x, hit.point.y, hit.point.z); + } } } else { blasterAmmo = blasterAmmo - 1; @@ -2003,6 +2208,7 @@ while (!windowShouldClose()) { enHP[j] = enHP[j] - BLASTER_DAMAGE; enFlashT[j] = DRETCH_HIT_FLASH; shotsHit = shotsHit + 1; + playSound3D(sfxImpactFlesh, hit.point.x, hit.point.y, hit.point.z); if (enHP[j] <= 0) { // Same death path as the rifle kill: corpse plays the die anim // at its last position; body leaves play immediately. @@ -2012,7 +2218,8 @@ while (!windowShouldClose()) { const pk2 = playerPosition(); enDeathYaw[j] = Math.atan2(pk2.x - enX[j], -(pk2.z - enZ[j])); setBodyPosition(enBody[j], vec3(enX[j], -100, enZ[j]), false); - playSound(sfxAttack); + playSound3D(sfxAlienDie[enKind[j] * 3 + (j % 3)], + enX[j], enY[j] + 1, enZ[j]); } break; } @@ -2050,6 +2257,16 @@ while (!windowShouldClose()) { W.ENV_SUN_I); if (PERFTEST) perfTB = getTime(); + // Round-7 — keep the audio listener on the camera so playSound3D + // (alien deaths/attacks, impacts, ricochets) pans and attenuates + // correctly. + { + const lfx = CAM[5] - CAM[2], lfy = CAM[6] - CAM[3], lfz = CAM[7] - CAM[4]; + const ll = Math.sqrt(lfx * lfx + lfy * lfy + lfz * lfz); + if (ll > 0.0001) { + setListenerPosition(CAM[2], CAM[3], CAM[4], lfx / ll, lfy / ll, lfz / ll); + } + } beginMode3D({ position: vec3(CAM[2], CAM[3], CAM[4]), target: vec3(CAM[5], CAM[6], CAM[7]), @@ -2114,32 +2331,17 @@ while (!windowShouldClose()) { vec3(0, 0, 0), 1.0, { r: 255, g: 255, b: 255, a: 255 }); } - // Forest scatter — ~120 trees pre-placed at startup. Through - // the tree wind-sway material when available; falls back to - // flat drawModel otherwise. Primitive 0 = trunk (warm brown), - // primitive 1 = leaves (green tinted with per-tree hue jitter). + // Forest scatter — ~120 leaf-card trees pre-placed at startup. + // Cached-model path: the scene shader gives alpha-cutout foliage + // wind sway + backlit transmission, the cutout shadow pipeline + // gives dappled shadows, and the planar probe reflects them in + // the river. Per-tree yaw + subtle whole-model hue jitter keep + // the three variants from reading as copy-paste. for (let i = 0; i < FOREST_COUNT; i++) { const pos = vec3(FOREST_X[i], FOREST_Y[i], FOREST_Z[i]); const v = treeVariants[FOREST_VAR[i]]; - const sc = FOREST_SCALE[i]; - if (matTree > 0) { - // Single per-tree leaf tint. Material handles trunk-vs-leaf - // split internally based on local-y, so we can render every - // primitive of the GLB with the same colour and the trunk - // still reads as brown. - const leaves = { - r: Math.max(0, Math.min(255, FOREST_TINT_R[i] - 165)), - g: Math.max(0, Math.min(255, FOREST_TINT_G[i] - 85)), - b: Math.max(0, Math.min(255, FOREST_TINT_B[i] - 195)), - a: 255 }; - const meshCount = TREE_MESH_COUNTS[FOREST_VAR[i]]; - for (let mIdx = 0; mIdx < meshCount; mIdx++) { - drawMeshWithMaterial(matTree, v as any, pos, sc, leaves, mIdx); - } - } else { - const fallback = { r: FOREST_TINT_R[i], g: FOREST_TINT_G[i], b: FOREST_TINT_B[i], a: 255 }; - drawModel(v, pos, sc, fallback); - } + const tint = { r: FOREST_TINT_R[i], g: FOREST_TINT_G[i], b: FOREST_TINT_B[i], a: 255 }; + drawModelRotated(v, pos, FOREST_SCALE[i], FOREST_YAW[i], tint); } // Static meshes — either drawModel for real GLBs, or coloured drawCube // for placeholder _gizmo_box.glb entries. MESH_CATEGORY drives the cube @@ -2170,22 +2372,14 @@ while (!windowShouldClose()) { vec3(W.MESH_X[i], W.MESH_Y[i], W.MESH_Z[i]), W.MESH_SCALE[i], WHITE); } else if (mi === treePropIdx) { - // Tier 3b — pick a tree variant + scale jitter from a stable - // index hash. Wind-sway material when available; fall back - // to drawModel for the no-material case. - const v = treeVariants[i & 3]; + // World-authored trees — variant + yaw + scale jitter from a + // stable index hash so the same world always lays out the same. + const v = treeVariants[i % 3]; const scaleJitter = 0.85 + ((i * 17) & 31) / 100.0; // 0.85 .. 1.16 - const sc = W.MESH_SCALE[i] * scaleJitter * 2.5; + const sc = W.MESH_SCALE[i] * scaleJitter * 1.15; const pos = vec3(W.MESH_X[i], W.MESH_Y[i], W.MESH_Z[i]); - if (matTree > 0) { - const leaves = { r: 80, g: 165, b: 55, a: 255 }; - const meshCount = TREE_MESH_COUNTS[i & 3]; - for (let mIdx = 0; mIdx < meshCount; mIdx++) { - drawMeshWithMaterial(matTree, v as any, pos, sc, leaves, mIdx); - } - } else { - drawModel(v, pos, sc, WHITE); - } + const yawDeg = ((i * 47) % 360); + drawModelRotated(v, pos, sc, yawDeg, WHITE); } else { drawModel(meshModelHandles[mi], vec3(W.MESH_X[i], W.MESH_Y[i], W.MESH_Z[i]), @@ -2308,8 +2502,11 @@ while (!windowShouldClose()) { const faceYaw = Math.atan2(dxA, -dzA); const attacking = Math.hypot(dxA, dzA) <= KIND_MELEE[k]; const animIdx = attacking ? ANIM_ATTACK_IDX[k] : ANIM_WALK_IDX[k]; + // Same engine quirk as the player model (see modelYaw above): the + // skinned path applies rotY INVERTED, so pass π/2 − yaw or the + // aliens strafe sideways-on toward the player. updateModelAnimation(animAliens[k], animIdx, enPhase[i], KIND_SCALE[k], - enX[i], enY[i], enZ[i], faceYaw); + enX[i], enY[i], enZ[i], Math.PI / 2 - faceYaw); const f = enFlashT[i] > 0 ? enFlashT[i] / DRETCH_HIT_FLASH : 0; const tint = f > 0 ? { r: 255, @@ -2332,7 +2529,7 @@ while (!windowShouldClose()) { const at = enDeathT[i] < dur - 0.04 ? enDeathT[i] : dur - 0.04; const sink = enDeathT[i] > dur + 0.6 ? (enDeathT[i] - dur - 0.6) * 0.9 : 0; updateModelAnimation(animAliens[k], ANIM_DIE_IDX[k], at, KIND_SCALE[k], - enX[i], enY[i] - sink, enZ[i], enDeathYaw[i]); + enX[i], enY[i] - sink, enZ[i], Math.PI / 2 - enDeathYaw[i]); drawModel(mdlAliens[k], vec3(enX[i], enY[i] - sink, enZ[i]), KIND_SCALE[k], WHITE); if (enDeathT[i] > dur + 2.0) enDying[i] = 0; } diff --git a/tools/build-props.ts b/tools/build-props.ts index 2ad13f4..25bb88e 100644 --- a/tools/build-props.ts +++ b/tools/build-props.ts @@ -22,7 +22,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { execSync } from 'node:child_process'; import { dirname } from 'node:path'; -import { encodePng, leafTexture, barkTexture, grassBladeTexture, flowerTexture, +import { encodePng, decodePng, leafTexture, barkTexture, grassBladeTexture, flowerTexture, stoneTexture, woodTexture, metalTexture, floorTexture, heightToNormal, roughnessMR } from './png'; @@ -208,9 +208,190 @@ const metalRgba = metalTexture(256); const floorRgba = floorTexture(512); const barkRgba = barkTexture(256); +// ---- Round-5: external CC0 scans (assets/textures/external, see the +// SOURCES.md there). All optional — a checkout without the downloads +// falls back to the procedural textures below. +const EXT = 'assets/textures/external'; + +// Read an external file as raw bytes, or null when absent. JPEGs embed +// into the GLB as-is (writeGlb sniffs the magic and sets image/jpeg). +function extBytes(rel: string): Uint8Array | null { + const p = EXT + '/' + rel; + return existsSync(p) ? new Uint8Array(readFileSync(p)) : null; +} + +// Composite the leaf-card cutout texture from REAL scanned leaves +// (ambientCG LeafSet004: six leaves in a 3×2 grid, separate opacity +// map). Leaves are stamped into a few clusters, leaving the border and +// inter-cluster channels transparent — the big low-frequency holes that +// survive mip averaging (see the alpha-mip note on leafTexture). +function externalLeafCard(size: number): Uint8Array | null { + const colBytes = extBytes('leafset/LeafSet004/LeafSet004_2K-PNG_Color.png'); + const opaBytes = extBytes('leafset/LeafSet004/LeafSet004_2K-PNG_Opacity.png'); + if (!colBytes || !opaBytes) return null; + const col = decodePng(colBytes); + const opa = decodePng(opaBytes); + const W = col.width, H = col.height; + const CW = Math.floor(W / 3), CH = Math.floor(H / 2); + const out = new Uint8Array(size * size * 4); // starts fully transparent + // Transparent texels keep a mid-green RGB so bilinear filtering at leaf + // edges doesn't bleed black fringes into the kept texels. + for (let i = 0; i < size * size; i++) { + out[i * 4] = 58; out[i * 4 + 1] = 92; out[i * 4 + 2] = 38; + } + let seed = 613291; + const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; + const clusters = [ + { cx: 0.34, cy: 0.36, r: 0.20, n: 9 }, + { cx: 0.68, cy: 0.30, r: 0.18, n: 8 }, + { cx: 0.52, cy: 0.66, r: 0.22, n: 10 }, + { cx: 0.24, cy: 0.72, r: 0.15, n: 6 }, + ]; + for (const cl of clusters) { + for (let i = 0; i < cl.n; i++) { + const leaf = Math.floor(rnd() * 6) % 6; + const lu0 = (leaf % 3) * CW, lv0 = Math.floor(leaf / 3) * CH; + const ang = rnd() * Math.PI * 2; + const ca = Math.cos(ang), sa = Math.sin(ang); + // Leaf spans ~18–32% of the card. + const scale = (0.18 + rnd() * 0.14) * size / Math.max(CW, CH); + const th = rnd() * Math.PI * 2, rr = Math.sqrt(rnd()) * cl.r; + const px = (cl.cx + Math.cos(th) * rr) * size; + const py = (cl.cy + Math.sin(th) * rr) * size; + const shade = 0.72 + rnd() * 0.40; // per-leaf light variation + const hue = (rnd() - 0.5) * 0.16; // warm/cool jitter + const half = 0.5 * scale * Math.max(CW, CH) * 1.45; // rotated diagonal + const x0 = Math.max(0, Math.floor(px - half)), x1 = Math.min(size - 1, Math.ceil(px + half)); + const y0 = Math.max(0, Math.floor(py - half)), y1 = Math.min(size - 1, Math.ceil(py + half)); + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + // Inverse-rotate destination → source-cell coords. + const dx = x - px, dy = y - py; + const sx = ( ca * dx + sa * dy) / scale + CW * 0.5; + const sy = (-sa * dx + ca * dy) / scale + CH * 0.5; + if (sx < 0 || sy < 0 || sx >= CW || sy >= CH) continue; + const si = (lv0 + (sy | 0)) * W + (lu0 + (sx | 0)); + if (opa.rgba[si * 4] < 128) continue; // opacity map, grey in R + const oi = (y * size + x) * 4; + out[oi] = Math.min(255, Math.floor(col.rgba[si * 4] * shade * (1 + hue))); + out[oi + 1] = Math.min(255, Math.floor(col.rgba[si * 4 + 1] * shade)); + out[oi + 2] = Math.min(255, Math.floor(col.rgba[si * 4 + 2] * shade * (1 - hue))); + out[oi + 3] = 255; + } + } + } + } + return encodePng(size, size, out); +} + +// Round-5b — ShareTextures branch atlases (CC0): whole scanned leaves / +// pinnate fronds stamped as large elements. One species per tree +// variant. Element rects are hand-read from the opacity maps (u0,v0,u1,v1). +interface Atlas { + col: { width: number; height: number; rgba: Uint8Array }; + opa: { width: number; height: number; rgba: Uint8Array }; + rects: [number, number, number, number][]; +} +function loadAtlas(colRel: string, opaRel: string, + rects: [number, number, number, number][]): Atlas | null { + const cb = extBytes(colRel), ob = extBytes(opaRel); + if (!cb || !ob) return null; + return { col: decodePng(cb), opa: decodePng(ob), rects }; +} +const atlasPlatanus = loadAtlas( + 'platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_basecolor-2K.png', + 'platanus/PlatanusOccidentalis_1-2K/PlatanusOccidentalis_1_opacity-2K.png', + [ + [0.02, 0.02, 0.42, 0.40], // leaf, pale + [0.40, 0.00, 1.00, 0.46], // leaf, big dark + [0.20, 0.44, 0.85, 1.00], // leaf, huge + [0.04, 0.40, 0.26, 0.66], // small sprig with stems + ]); +const atlasRobinia = loadAtlas( + 'robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_basecolor-4K.png', + 'robinia/RobiniaViscosa_2-4K/RobiniaViscosa_2_opaciy-4K.png', // sic: pack typo + [ + [0.00, 0.00, 0.47, 1.00], // left frond + [0.50, 0.01, 1.00, 0.95], // right frond + ]); + +// Stamp whole atlas elements into a card texture. Fewer, BIGGER stamps +// than the per-leaf compositor — a card reads as one or two overlapping +// branch clusters, with the card border + inter-stamp gaps providing the +// low-frequency transparency. +function atlasCard(size: number, atlas: Atlas, seed0: number, stamps: number): Uint8Array { + const { col, opa, rects } = atlas; + const W = col.width, H = col.height; + const out = new Uint8Array(size * size * 4); + // Transparent texels keep a mid-green RGB (no black edge fringes). + for (let i = 0; i < size * size; i++) { + out[i * 4] = 58; out[i * 4 + 1] = 92; out[i * 4 + 2] = 38; + } + let seed = seed0; + const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; + for (let i = 0; i < stamps; i++) { + const rect = rects[Math.floor(rnd() * rects.length) % rects.length]; + const rx0 = rect[0] * W, ry0 = rect[1] * H; + const rw = (rect[2] - rect[0]) * W, rh = (rect[3] - rect[1]) * H; + const ang = rnd() * Math.PI * 2; + const ca = Math.cos(ang), sa = Math.sin(ang); + // Element's longest side spans 55–85% of the card. + const scale = (0.55 + rnd() * 0.30) * size / Math.max(rw, rh); + const px = (0.30 + rnd() * 0.40) * size; + const py = (0.30 + rnd() * 0.40) * size; + const shade = 0.70 + rnd() * 0.42; + const hue = (rnd() - 0.5) * 0.14; + const half = 0.5 * scale * Math.hypot(rw, rh); + const x0 = Math.max(0, Math.floor(px - half)), x1 = Math.min(size - 1, Math.ceil(px + half)); + const y0 = Math.max(0, Math.floor(py - half)), y1 = Math.min(size - 1, Math.ceil(py + half)); + for (let y = y0; y <= y1; y++) { + for (let x = x0; x <= x1; x++) { + const dx = x - px, dy = y - py; + const sx = ( ca * dx + sa * dy) / scale + rw * 0.5; + const sy = (-sa * dx + ca * dy) / scale + rh * 0.5; + if (sx < 0 || sy < 0 || sx >= rw || sy >= rh) continue; + const si = ((ry0 + sy) | 0) * W + ((rx0 + sx) | 0); + if (opa.rgba[si * 4] < 128) continue; + const oi = (y * size + x) * 4; + out[oi] = Math.min(255, Math.floor(col.rgba[si * 4] * shade * (1 + hue))); + out[oi + 1] = Math.min(255, Math.floor(col.rgba[si * 4 + 1] * shade)); + out[oi + 2] = Math.min(255, Math.floor(col.rgba[si * 4 + 2] * shade * (1 - hue))); + out[oi + 3] = 255; + } + } + } + return out; +} + +const extLeafCard = externalLeafCard(512); +const extBark = extBytes('pine_bark/pine_bark_diff_1k.jpg'); +const extBarkN = extBytes('pine_bark/pine_bark_nor_gl_1k.jpg'); +// Grayscale roughness JPEG: decodes to R=G=B, and glTF reads roughness +// from G (metallic from B — harmless, metallicFactor is 0). +const extBarkMR = extBytes('pine_bark/pine_bark_rough_1k.jpg'); +if (extBark) console.log('bark: Poly Haven pine_bark 1K'); + +// Leaf card priority per tree variant: branch atlas (sycamore / robinia) +// → LeafSet004 compositor → procedural noise blobs. +const cardPlat1 = atlasPlatanus ? encodePng(512, 512, atlasCard(512, atlasPlatanus, 424711, 4)) : null; +const cardPlat2 = atlasPlatanus ? encodePng(512, 512, atlasCard(512, atlasPlatanus, 918273, 4)) : null; +const cardRobin = atlasRobinia ? encodePng(512, 512, atlasCard(512, atlasRobinia, 550137, 3)) : null; +if (cardPlat1) console.log('leaf cards: ShareTextures Platanus + Robinia atlases'); +else if (extLeafCard) console.log('leaf cards: scanned LeafSet004'); +{ + // Drop inspectable copies next to the other test artefacts. + mkdirSync('tools/.testout', { recursive: true }); + if (cardPlat1) writeFileSync('tools/.testout/leafcard_platanus.png', cardPlat1); + if (cardRobin) writeFileSync('tools/.testout/leafcard_robinia.png', cardRobin); + else if (extLeafCard) writeFileSync('tools/.testout/leafcard_preview.png', extLeafCard); +} +const fallbackLeaf = extLeafCard ?? encodePng(512, 512, leafTexture(512)); + const PROC_TEX: Record = { - leaf: encodePng(256, 256, leafTexture(256)), - bark: encodePng(256, 256, barkRgba), + leaf: cardPlat1 ?? fallbackLeaf, + leaf2: cardRobin ?? fallbackLeaf, + leaf3: cardPlat2 ?? fallbackLeaf, + bark: extBark ?? encodePng(256, 256, barkRgba), grass_blade: encodePng(256, 256, grassBladeTexture(256)), flower: encodePng(256, 256, flowerTexture(256)), // Stone/wood/metal/floor: procedural fallbacks so the building + props are @@ -229,13 +410,13 @@ const PROC_TEX: Record = { wood_n: encodePng(512, 512, heightToNormal(512, 512, woodRgba, 2.2)), metal_n: encodePng(256, 256, heightToNormal(256, 256, metalRgba, 1.0)), floor_n: encodePng(512, 512, heightToNormal(512, 512, floorRgba, 1.8)), - bark_n: encodePng(256, 256, heightToNormal(256, 256, barkRgba, 2.6)), + bark_n: extBarkN ?? encodePng(256, 256, heightToNormal(256, 256, barkRgba, 2.6)), // Per-texel roughness (metallic-roughness maps) — recessed mortar/grooves // read matte, faces a touch glossier; all dielectric (metallic 0). stone_mr: encodePng(512, 512, roughnessMR(512, 512, stoneRgba, 0.72, 0.97)), wood_mr: encodePng(512, 512, roughnessMR(512, 512, woodRgba, 0.62, 0.90)), floor_mr: encodePng(512, 512, roughnessMR(512, 512, floorRgba, 0.60, 0.88)), - bark_mr: encodePng(256, 256, roughnessMR(256, 256, barkRgba, 0.78, 0.97)), + bark_mr: extBarkMR ?? encodePng(256, 256, roughnessMR(256, 256, barkRgba, 0.78, 0.97)), }; // Albedo texture key → its normal-map key (solid materials only; the alpha @@ -356,29 +537,38 @@ function pushTrunk(m: Mesh, baseR: number, topR: number, height: number, m.push({ vertices: verts, indices, color, textureKey, roughness: 0.95, metallic: 0.0 }); } -function makeTree(): Mesh { +// Variant knobs: `seed0` re-rolls the whole card scatter, `trunkH` is the +// trunk height in metres (canopy rides on top), `spread` widens/narrows the +// canopy. Three variants ship so the forest doesn't read as one model +// copy-pasted (prop_tree / prop_tree2 / prop_tree3 at the bottom of this +// file). +function makeTree(seed0: number, trunkH: number, spread: number, leafKey: string): Mesh { const m: Mesh = []; // Deterministic pseudo-random so the GLB is reproducible. - let seed = 90187; + let seed = seed0; const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }; // Tapered, leaning, root-flared trunk (12 sides for a smooth-but-organic // profile) + a couple of low branch stubs so it isn't a bare pole. - pushTrunk(m, 0.28, 0.10, 3.0, 12, 0.22, 7, [1, 1, 1], 'bark'); - pushCylinder(m, 0.55, 2.05, 0.2, 0.07, 0.5, 6, [1, 1, 1], 0.95, 0.0, 'bark'); - pushCylinder(m, -0.5, 2.35, -0.3, 0.06, 0.45, 6, [1, 1, 1], 0.95, 0.0, 'bark'); + pushTrunk(m, 0.28 * Math.sqrt(trunkH / 3.0), 0.10, trunkH, 12, 0.22, 7, [1, 1, 1], 'bark'); + pushCylinder(m, 0.55 * spread, trunkH * 0.68, 0.2, 0.07, 0.5, 6, [1, 1, 1], 0.95, 0.0, 'bark'); + pushCylinder(m, -0.5 * spread, trunkH * 0.78, -0.3, 0.06, 0.45, 6, [1, 1, 1], 0.95, 0.0, 'bark'); const fv: number[] = [], fi: number[] = []; // Canopy = several overlapping leaf-card CLUMPS (foliage masses) rather than // symmetric horizontal rings — gives a rounder, ragged, non-boxy silhouette. // Each clump scatters cards through a squashed sphere with size + tilt jitter. + // Clump heights ride on trunkH; radii/offsets scale with spread. + // Round-4: more, SMALLER cards. Big 1.35–2.5 m cards read as flat green + // sheets — the canopy silhouette has to come from card layout, not from + // a handful of billboards. const clumps = [ - { cx: 0.0, cy: 3.5, cz: 0.0, rad: 1.55, n: 30 }, - { cx: 0.95, cy: 3.05, cz: 0.45, rad: 1.15, n: 18 }, - { cx: -0.85, cy: 3.15, cz: -0.55, rad: 1.15, n: 18 }, - { cx: 0.25, cy: 4.2, cz: -0.35, rad: 1.0, n: 15 }, - { cx: -0.35, cy: 2.7, cz: 0.75, rad: 0.95, n: 13 }, - { cx: 0.6, cy: 3.7, cz: -0.7, rad: 0.9, n: 12 }, + { cx: 0.0, cy: trunkH + 0.50, cz: 0.0, rad: 1.55 * spread, n: 44 }, + { cx: 0.95 * spread, cy: trunkH + 0.05, cz: 0.45 * spread, rad: 1.15 * spread, n: 26 }, + { cx: -0.85 * spread, cy: trunkH + 0.15, cz: -0.55 * spread, rad: 1.15 * spread, n: 26 }, + { cx: 0.25 * spread, cy: trunkH + 1.20, cz: -0.35 * spread, rad: 1.0 * spread, n: 21 }, + { cx: -0.35 * spread, cy: trunkH - 0.30, cz: 0.75 * spread, rad: 0.95 * spread, n: 18 }, + { cx: 0.6 * spread, cy: trunkH + 0.70, cz: -0.7 * spread, rad: 0.9 * spread, n: 17 }, ]; for (const cl of clumps) { for (let i = 0; i < cl.n; i++) { @@ -390,20 +580,20 @@ function makeTree(): Mesh { const py = cl.cy + rr * uu * 0.82; const pz = cl.cz + rr * sq * Math.sin(th); const yaw = Math.atan2(px - cl.cx, pz - cl.cz) + (rnd() - 0.5) * 0.7; - const size = 1.35 + rnd() * 1.15; + const size = 0.90 + rnd() * 0.75; addLeafCard(fv, fi, px, py, pz, size, size, yaw, rnd() * 0.95); } } // A few ragged outliers poking past the clumps so the edge isn't a clean ball. - for (let i = 0; i < 10; i++) { + for (let i = 0; i < 14; i++) { const yaw = rnd() * Math.PI * 2; - const r = 1.7 + rnd() * 0.7; - addLeafCard(fv, fi, Math.sin(yaw) * r, 3.3 + (rnd() - 0.3) * 2.0, - Math.cos(yaw) * r, 1.0 + rnd() * 0.9, 1.0 + rnd() * 0.9, + const r = (1.7 + rnd() * 0.7) * spread; + addLeafCard(fv, fi, Math.sin(yaw) * r, trunkH + 0.3 + (rnd() - 0.3) * 2.0, + Math.cos(yaw) * r, 0.75 + rnd() * 0.65, 0.75 + rnd() * 0.65, yaw, rnd() * 0.8); } m.push({ - vertices: fv, indices: fi, color: [1, 1, 1], textureKey: 'leaf', + vertices: fv, indices: fi, color: [1, 1, 1], textureKey: leafKey, roughness: 0.9, metallic: 0.0, alphaMode: 'MASK', alphaCutoff: 0.5, }); return m; @@ -737,7 +927,13 @@ function writeGlb(outPath: string, mesh: Mesh): void { gltf.samplers = [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }]; gltf.textures = texBytes.map((_, i) => ({ source: i, sampler: 0 })); gltf.images = texBytes - .map((_, i) => imageBv[i] >= 0 ? { bufferView: imageBv[i], mimeType: 'image/png' } : null) + // Round-5: sniff the container — external Poly Haven maps are JPEG + // (0xFFD8 magic) and glTF supports image/jpeg directly, so they embed + // without a re-encode. + .map((b, i) => imageBv[i] >= 0 + ? { bufferView: imageBv[i], + mimeType: (b.length > 2 && b[0] === 0xff && b[1] === 0xd8) ? 'image/jpeg' : 'image/png' } + : null) .filter(x => x !== null); } @@ -813,7 +1009,9 @@ function makeCalibRig(): Mesh { return m; } -writeGlb('assets/models/prop_tree.glb', makeTree()); +writeGlb('assets/models/prop_tree.glb', makeTree(90187, 3.0, 1.0, 'leaf')); // sycamore +writeGlb('assets/models/prop_tree2.glb', makeTree(41627, 3.9, 0.85, 'leaf2')); // robinia +writeGlb('assets/models/prop_tree3.glb', makeTree(77321, 2.5, 1.3, 'leaf3')); // sycamore mix writeGlb('assets/models/calib_rig.glb', makeCalibRig()); writeGlb('assets/models/prop_grasstuft.glb', makeGrassTuft()); writeGlb('assets/models/prop_flower.glb', makeFlowerTuft()); diff --git a/tools/f12-shot.ps1 b/tools/f12-shot.ps1 new file mode 100644 index 0000000..fac95d7 --- /dev/null +++ b/tools/f12-shot.ps1 @@ -0,0 +1,31 @@ +# f12-shot.ps1 — run the game, PostMessage F12 at T seconds, collect the engine's own 4K screenshot. +param([int]$At = 26) +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName Microsoft.VisualBasic +Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class Win32Post { + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); +} +'@ +$proj = 'C:\Users\Ralph\projects\bloom\shooter' +Set-Location $proj +# Snapshot the existing screenshots so we can identify the NEW one afterward +# (do NOT delete anything — these files may be the user's). +$before = @(Get-ChildItem "$proj\screenshot_*.png" -ErrorAction SilentlyContinue | ForEach-Object Name) +Get-Process main -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Milliseconds 400 +$game = Start-Process "$proj\main.exe" -WorkingDirectory $proj -PassThru -RedirectStandardError "$proj\_run_err.txt" +Start-Sleep -Seconds $At +if ($game.HasExited) { Write-Host "GAME EXITED code=$($game.ExitCode)"; exit 1 } +$game.Refresh() +$hwnd = $game.MainWindowHandle +# WM_KEYDOWN=0x100, WM_KEYUP=0x101, VK_F12=0x7B +[Win32Post]::PostMessage($hwnd, 0x100, [IntPtr]0x7B, [IntPtr]0) | Out-Null +Start-Sleep -Milliseconds 120 +[Win32Post]::PostMessage($hwnd, 0x101, [IntPtr]0x7B, [IntPtr]0) | Out-Null +Start-Sleep -Seconds 2 +Get-Process main -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +$shot = Get-ChildItem "$proj\screenshot_*.png" | Where-Object { $before -notcontains $_.Name } | Sort-Object LastWriteTime | Select-Object -Last 1 +if ($shot) { Write-Host "shot: $($shot.Name)" } else { Write-Host "no screenshot produced"; Select-String -Path "$proj\_run_err.txt" -Pattern 'screenshot' | Select-Object -Last 3 -ExpandProperty Line } diff --git a/tools/png.ts b/tools/png.ts index df9723c..4443ee3 100644 --- a/tools/png.ts +++ b/tools/png.ts @@ -4,7 +4,7 @@ // an external image library. Output is a valid PNG that Bloom's texture // loader (stb_image → wgpu) accepts. -import { deflateSync } from 'node:zlib'; +import { deflateSync, inflateSync } from 'node:zlib'; const CRC_TABLE = (() => { const t = new Uint32Array(256); @@ -77,6 +77,97 @@ export function encodePng(width: number, height: number, rgba: Uint8Array): Uint return out; } +// Round-5 — minimal PNG decoder, the read-side counterpart for external +// scanned textures (ambientCG leaf sets etc.). Supports 8-bit depth, +// colour types 0 (grey) / 2 (RGB) / 3 (indexed) / 4 (grey+alpha) / +// 6 (RGBA), non-interlaced. Returns straight (non-premultiplied) RGBA. +export function decodePng(bytes: Uint8Array): { width: number; height: number; rgba: Uint8Array } { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < 24 || dv.getUint32(0, false) !== 0x89504e47 || dv.getUint32(4, false) !== 0x0d0a1a0a) { + throw new Error('decodePng: not a PNG'); + } + let pos = 8; + let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0; + let palette: Uint8Array | null = null; + let trns: Uint8Array | null = null; + const idat: Uint8Array[] = []; + while (pos + 8 <= bytes.length) { + const len = dv.getUint32(pos, false); + const type = String.fromCharCode(bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]); + const data = bytes.subarray(pos + 8, pos + 8 + len); + if (type === 'IHDR') { + width = dv.getUint32(pos + 8, false); + height = dv.getUint32(pos + 12, false); + bitDepth = bytes[pos + 16]; + colorType = bytes[pos + 17]; + interlace = bytes[pos + 20]; + } else if (type === 'PLTE') palette = data; + else if (type === 'tRNS') trns = data; + else if (type === 'IDAT') idat.push(data); + else if (type === 'IEND') break; + pos += 12 + len; + } + if (bitDepth !== 8) throw new Error('decodePng: only 8-bit depth supported (got ' + bitDepth + ')'); + if (interlace !== 0) throw new Error('decodePng: interlaced PNGs unsupported'); + const channels = colorType === 0 ? 1 : colorType === 2 ? 3 : colorType === 3 ? 1 + : colorType === 4 ? 2 : colorType === 6 ? 4 : 0; + if (channels === 0) throw new Error('decodePng: unsupported colour type ' + colorType); + const total = idat.reduce((n, c) => n + c.length, 0); + const comp = new Uint8Array(total); + { let o = 0; for (const c of idat) { comp.set(c, o); o += c.length; } } + const raw = new Uint8Array(inflateSync(comp)); + const stride = width * channels; + const rgba = new Uint8Array(width * height * 4); + const prev = new Uint8Array(stride); + const cur = new Uint8Array(stride); + let rp = 0; + for (let y = 0; y < height; y++) { + const filter = raw[rp++]; + cur.set(raw.subarray(rp, rp + stride)); + rp += stride; + // Unfilter in place — cur[i - channels] is already reconstructed + // when we reach index i (ascending order). + for (let i = 0; i < stride; i++) { + const a = i >= channels ? cur[i - channels] : 0; + const b = prev[i]; + const c = i >= channels ? prev[i - channels] : 0; + let x = cur[i]; + if (filter === 1) x = (x + a) & 0xff; + else if (filter === 2) x = (x + b) & 0xff; + else if (filter === 3) x = (x + ((a + b) >> 1)) & 0xff; + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + x = (x + (pa <= pb && pa <= pc ? a : (pb <= pc ? b : c))) & 0xff; + } + cur[i] = x; + } + prev.set(cur); + for (let x = 0; x < width; x++) { + const o = (y * width + x) * 4; + const s = x * channels; + if (colorType === 0) { + const g = cur[s]; + rgba[o] = g; rgba[o + 1] = g; rgba[o + 2] = g; rgba[o + 3] = 255; + } else if (colorType === 2) { + rgba[o] = cur[s]; rgba[o + 1] = cur[s + 1]; rgba[o + 2] = cur[s + 2]; rgba[o + 3] = 255; + } else if (colorType === 3) { + const idx = cur[s], pi = idx * 3; + rgba[o] = palette ? palette[pi] : 0; + rgba[o + 1] = palette ? palette[pi + 1] : 0; + rgba[o + 2] = palette ? palette[pi + 2] : 0; + rgba[o + 3] = trns && idx < trns.length ? trns[idx] : 255; + } else if (colorType === 4) { + const g = cur[s]; + rgba[o] = g; rgba[o + 1] = g; rgba[o + 2] = g; rgba[o + 3] = cur[s + 1]; + } else { + rgba[o] = cur[s]; rgba[o + 1] = cur[s + 1]; rgba[o + 2] = cur[s + 2]; rgba[o + 3] = cur[s + 3]; + } + } + } + return { width, height, rgba }; +} + // ----------------------------------------------------------------------------- // Procedural generators // ----------------------------------------------------------------------------- @@ -262,14 +353,16 @@ export function leafTexture(size: number): Uint8Array { // "zero-centred ±0.3" comment worked around). return makeTexture(s, s, (x, y) => { const u = x / s, v = y / s; - const clump = fbm(u * 4.5, v * 4.5, 4); // [0,1] large foliage masses - const detail = fbm(u * 22, v * 22, 3); // [0,1] leaf-scale nibbling - // Coverage field: ~60% of the card opaque, organic clumps with real sky - // gaps. Zero-centre the noise around the cutoff explicitly. - let mask = 0.62 + (clump - 0.5) * 2.4 + (detail - 0.5) * 1.2; + const clump = fbm(u * 3.0, v * 3.0, 3); // [0,1] large foliage masses + const detail = fbm(u * 16, v * 16, 3); // [0,1] leaf-scale nibbling + // Coverage field ~55%, dominated by LOW-frequency clumps so the sky + // gaps are a few big contiguous holes per card. Small dithered gaps + // disappear once mip levels average the alpha (mean 0.6 > cutoff 0.5 + // everywhere) — distant cards rendered as solid green sheets. + let mask = 0.55 + (clump - 0.5) * 3.2 + (detail - 0.5) * 0.9; // Fade the card border to transparent so quads don't show a hard square. const edge = Math.min(Math.min(u, 1 - u), Math.min(v, 1 - v)); - mask *= fade(Math.min(1, edge / 0.12)); + mask *= fade(Math.min(1, edge / 0.16)); const a = mask > 0.5 ? 255 : 0; // Realistic foliage albedo: deep greens ~sRGB(58,93,31) with warm/cool // per-leaf variation, highlights capped well below sRGB 140 so sunlit