From e3f71590db4ab3758211614cf205a09775b2fd37 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 06:49:37 -0700 Subject: [PATCH 01/17] hook: resolve the PR-watch guard script by $CLAUDE_PROJECT_DIR The PreToolUse hook loaded its jq script by a relative path, so every Bash call failed the moment the shell's cwd moved out of the repo root -- and because the hook gates Bash itself, cd-ing back was impossible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index e2b46bf590..83b74867ef 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "jq -c -f \".claude/hooks/monitor_guard.jq\"", + "command": "jq -c -f \"$CLAUDE_PROJECT_DIR/.claude/hooks/monitor_guard.jq\"", "timeout": 15, "statusMessage": "PR-watch guard: pr-babysit is the tool" } From 56bd543b0f0b859453f391ed12e14c772b3c4669 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 06:49:55 -0700 Subject: [PATCH 02/17] River Run: cinematic render pipeline, real models, redesigned HUD The example ran, but it did not look like something to put on the site: the player was a sphere with a rotor cross, the banks were a flat plane with an axis-aligned checkerboard baked in by a floor()-quantized hash, there was no sky or horizon at all, and the HUD was laid out in raw framebuffer pixels so it came out half size on any HiDPI display. Rendering is now a deferred-lit forward pass into an HDR MRT target followed by a screen-space chain: SSAO, progressive-downsample bloom, screen-space sun shafts, ACES tone mapping, per-section colour grade, vignette, grain and FXAA. A single ortho shadow cascade replaces the fake planar blobs, so props shade each other and the banks. Everything is held to the GLSL ES 3.00 / WebGL2 feature set, and the HDR attachment is probed at creation and falls back to RGBA8, so the wasm build stays viable. rr_shaders.das scene shading: sky, terrain, water, props, rotor, shadow rr_postfx.das render targets and the post chain rr_models.das helicopter, gunboat and jet welded from primitives into one draw call each, part index baked into uv.x rr_live.das live-command surface (status/reset/section/spawn/god mode) Each of the ten sections is now a whole lighting environment -- sun colour and elevation, sky, fog, exposure, grade -- crossfaded on section change, so the run reads as flying through changing weather rather than a river tint swap. The banks are sculpted from an analytic height field with a real shoreline profile; gameplay shares the same terrain_height so props stand on the ground. The HUD is rebuilt in design pixels against a 720p reference and scaled, with a framed instrument cluster, life icons drawn as the player's own silhouette, a section progress rail, and outlined type. Also fixed, both found while iterating: - Live reload aborted in Stream::push on a dead mutex. audio_initialized and music_initialized were @live, so they came back true after a reload, but shutdown_audio() had already torn the audio system down and asch is not @live -- the new context drove a destroyed audio system and a strudel Stream owned by the freed old heap. They describe live process state, not game state, so they no longer persist. - Reload emptied the world: the game never required live/decs_live, and init() correctly skips re-spawning under is_reload(). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/gameplay.das | 572 +++++++++--------- examples/games/river_run/hud.das | 181 ++++-- examples/games/river_run/hud3d.das | 396 +++++++++---- examples/games/river_run/main.das | 243 ++++++-- examples/games/river_run/river.das | 319 +++++++--- examples/games/river_run/rr_audio.das | 14 +- examples/games/river_run/rr_globals.das | 447 +++++++------- examples/games/river_run/rr_live.das | 174 ++++++ examples/games/river_run/rr_models.das | 261 +++++++++ examples/games/river_run/rr_postfx.das | 743 ++++++++++++++++++++++++ examples/games/river_run/rr_shaders.das | 543 +++++++++++++++++ 11 files changed, 3128 insertions(+), 765 deletions(-) create mode 100644 examples/games/river_run/rr_live.das create mode 100644 examples/games/river_run/rr_models.das create mode 100644 examples/games/river_run/rr_postfx.das create mode 100644 examples/games/river_run/rr_shaders.das diff --git a/examples/games/river_run/gameplay.das b/examples/games/river_run/gameplay.das index 6f8d1f13e8..5f145e0227 100644 --- a/examples/games/river_run/gameplay.das +++ b/examples/games/river_run/gameplay.das @@ -162,9 +162,10 @@ def spawn_island(pos : float3) { } def spawn_river_tree(pos : float3; size : float; tiers : int; green_tint, green_shift, trunk_ratio : float) { + let grounded = float3(pos.x, pos.y, terrain_height(pos.x, pos.y)) create_entity() @(eid, cmp) { apply_decs_template(cmp, RiverTree( - pos = pos, + pos = grounded, size = size, tiers = tiers, green_tint = green_tint, @@ -175,9 +176,18 @@ def spawn_river_tree(pos : float3; size : float; tiers : int; green_tint, green_ } def spawn_river_house(pos : float3; size, body_tint, roof_tint : float) { + // Sample the footprint corners and stand on the highest one, so a house on + // a slope rests on the ground instead of being half-buried by it. + var ground = terrain_height(pos.x, pos.y) + for (dx in fixed_array(-size, size)) { + for (dy in fixed_array(-size, size)) { + ground = max(ground, terrain_height(pos.x + dx, pos.y + dy)) + } + } + let grounded = float3(pos.x, pos.y, ground) create_entity() @(eid, cmp) { apply_decs_template(cmp, RiverHouse( - pos = pos, + pos = grounded, size = size, body_tint = body_tint, roof_tint = roof_tint @@ -471,13 +481,7 @@ def reset_game() { slow_mo_timer = 0.0 low_fuel_beep_timer = 0.0 refuel_sound_timer = 0.0 - river_color_from = section_colors[0] - river_color_to = section_colors[0] - bank_color_from = bank_colors[0] - bank_color_to = bank_colors[0] - fog_color_from = fog_colors[0] - fog_color_to = fog_colors[0] - section_color_blend_t = 1.0 + set_env_immediate(0) global_rng_seed = uint(get_uptime() * 1000.0) init_river() @@ -579,13 +583,20 @@ def update_player_movement() { } // Fuel - player_fuel = max(player_fuel - PLAYER_FUEL_DRAIN * tick_dt, 0.0) + if (!god_mode) { + player_fuel = max(player_fuel - PLAYER_FUEL_DRAIN * tick_dt, 0.0) + } if (player_fuel <= 0.0 && player_respawn_timer <= 0.0) { on_player_killed() } } def on_player_killed() { + if (god_mode) { + player_invuln_timer = max(player_invuln_timer, PLAYER_INVULN_TIME) + player_fuel = PLAYER_FUEL_MAX + return + } player_lives-- spawn_particles(player_pos, float3(1.0, 0.5, 0.2), PLAYER_SIZE * 2.6, 34) play_sfx(snd_player_hit) @@ -644,7 +655,12 @@ def update_section() { section_dist += player_fwd_speed * tick_dt section_banner_timer = max(section_banner_timer - tick_dt, 0.0) - section_color_blend_t = min(section_color_blend_t + tick_dt * 0.7, 1.0) + if (section_color_blend_t < 1.0) { + // The whole lighting environment crossfades, not just the water tint, + // so a section change reads as flying into different weather. + section_color_blend_t = min(section_color_blend_t + tick_dt * 0.35, 1.0) + refresh_env() + } if (section_dist >= SECTION_LENGTH) { section_dist -= SECTION_LENGTH @@ -652,14 +668,7 @@ def update_section() { section_banner_timer = WAVE_BANNER_DURATION play_sfx(snd_section_clear) score += 500 * (current_section) - // Start smooth color transition to new section palette - river_color_from = get_river_color() - bank_color_from = get_bank_color() - fog_color_from = get_fog_color() - river_color_to = section_colors[section_idx()] - bank_color_to = bank_colors[section_idx()] - fog_color_to = fog_colors[section_idx()] - section_color_blend_t = 0.0 + begin_env_transition(section_idx()) if (current_section >= MAX_SECTIONS) { game_state = GameState.win_state @@ -1268,121 +1277,164 @@ def quat_align_z(v : float3) : float4 { return float4(axis * sin(half), cos(half)) } -// --- Shadows --- -// Flat shadow projection along LIGHT_DIR (matches phong lighting direction). -// LIGHT_DIR = (0.4, -0.6, -1.0) — only x/z and y/z ratios matter for projection. +// --- Model palettes --- +// +// Enemy factions share the player's meshes under different paint, which keeps +// the vocabulary readable: silhouette says what it is, colour says whose it is. -let SHADOW_Z = 0.02 -let SHADOW_OFFSET_X = 0.4 -let SHADOW_OFFSET_Y = -0.6 +def private player_palette(inv : bool; pulse : float) { + let base = (inv ? float3(1.0, 0.86, 0.35) : float3(0.72, 0.80, 0.88)) + set_model_palette( + base * pulse, + float3(0.16, 0.30, 0.38), + float3(0.52, 0.56, 0.60), + float3(0.10, 0.12, 0.14) + ) +} -def shadow_pos(p : float3) : float3 { - return float3(p.x + p.z * SHADOW_OFFSET_X, p.y + p.z * SHADOW_OFFSET_Y, SHADOW_Z) +def private enemy_heli_palette() { + set_model_palette( + float3(0.52, 0.14, 0.10), + float3(0.14, 0.10, 0.10), + float3(0.40, 0.38, 0.36), + float3(0.08, 0.07, 0.07) + ) } -def draw_shadow(pos : float3; sx, sy, alpha : float; round : bool) { - v_model = compose(shadow_pos(pos), float4(0.0, 0.0, 0.0, 1.0), float3(sx, sy, 0.001)) - f_Color = float4(0.0, 0.0, 0.0, alpha) - vs_flat_bind_uniform(active_program) - fs_flat_bind_uniform(active_program) - if (round) { - geo_cylinder |> draw_geometry_fragment() - } else { - geo_cube |> draw_geometry_fragment() - } +def private boat_palette() { + set_model_palette( + float3(0.28, 0.30, 0.27), + float3(0.46, 0.42, 0.30), + float3(0.48, 0.47, 0.45), + float3(0.09, 0.10, 0.11) + ) } -// Shadow streak for a vertical pillar standing on the water (base at z=0). -// Anchors at base XY and stretches along LIGHT_DIR projection so the shadow -// visibly connects to the foot of the pillar. -def draw_pillar_shadow(base_x, base_y : float; height, half_thickness, alpha : float) { - let dx = height * SHADOW_OFFSET_X - let dy = height * SHADOW_OFFSET_Y - let cx = base_x + dx * 0.5 - let cy = base_y + dy * 0.5 - let len = sqrt(dx * dx + dy * dy) - let angle = atan2(dy, dx) - v_model = compose( - float3(cx, cy, SHADOW_Z), - quat_z_rot(angle), - float3(len * 0.5, half_thickness, 0.001) +def private jet_palette() { + set_model_palette( + float3(0.40, 0.42, 0.47), + float3(0.14, 0.18, 0.22), + float3(0.55, 0.55, 0.58), + float3(0.08, 0.08, 0.09) ) - f_Color = float4(0.0, 0.0, 0.0, alpha) - vs_flat_bind_uniform(active_program) - fs_flat_bind_uniform(active_program) - geo_cube |> draw_geometry_fragment() } -def render_shadows(game_time : float) { - glUseProgram(flat_program) - active_program = flat_program - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glDepthMask(false) +// Craft bank into a turn. Purely cosmetic, but it is most of what makes the +// helicopter feel like it is being flown rather than slid. +def private player_orientation() : float4 { + let roll = clamp(-player_vel.x * 0.075, -0.55, 0.55) + let pitch = clamp((player_fwd_speed - PLAYER_FWD_SPEED_MIN) * 0.018, 0.0, 0.22) + return quat_mul(quat_y_rot(roll), quat_x_rot(-pitch)) +} - if (player_respawn_timer < PLAYER_RESPAWN_BLINK) { - let blinking = player_respawn_timer > 0.0 - if (!blinking || int(game_time * 16.0) % 2 != 0) { - draw_shadow(player_pos, PLAYER_SIZE * 0.95, PLAYER_SIZE * 0.95, 0.55, true) - } +// --- Shadow map pass --- +// +// Everything solid re-draws into the light's depth buffer. The old fake blob +// shadows are gone: props now shade each other and the banks. + +// Aircraft and boats: the movers, whose shadows track the action. +def private shadow_cast_craft() { + if (player_respawn_timer <= PLAYER_RESPAWN_BLINK) { + draw_shadow_caster(player_pos, float3(PLAYER_SIZE * 1.85), player_orientation()) + geo_heli |> draw_geometry_fragment() } query() $(b : EnemyBoat) { - draw_shadow(b.pos, BOAT_SIZE * 0.65, BOAT_SIZE * 1.65, 0.45, false) + draw_shadow_caster(b.pos, float3(BOAT_SIZE * 1.25), quat_z_rot(b.patrol_dir * 0.12)) + geo_gunboat |> draw_geometry_fragment() } query() $(p : EnemyPlane) { - draw_shadow(p.pos, PLANE_SIZE * 1.5, PLANE_SIZE * 0.65, 0.45, true) + draw_shadow_caster(p.pos, float3(PLANE_SIZE * 1.35)) + geo_jet |> draw_geometry_fragment() } query() $(h : EnemyHelicopter) { - draw_shadow(h.pos, ENEMY_HELI_SIZE * 1.25, ENEMY_HELI_SIZE * 1.25, 0.5, true) + draw_shadow_caster(h.pos, float3(ENEMY_HELI_SIZE * 1.7), quat_y_rot(sin(h.hover_phase) * 0.2)) + geo_heli |> draw_geometry_fragment() } - query() $(b : BonusPickup) { - let bob = sin(game_time * BONUS_BOB_SPEED + b.bob_phase) * BONUS_BOB_AMPLITUDE - draw_shadow(b.pos + float3(0.0, 0.0, 0.55 + bob), 0.32, 0.32, 0.5, true) - } +} +// Bank scenery: trees and houses, which throw the long streaks across the +// ground that give the low sun its scale. +def private shadow_cast_scenery() { query() $(t : RiverTree) { - let r = t.size * 0.6 - draw_shadow(t.pos + float3(0.0, 0.0, t.size * 0.7), r, r, 0.5, true) + let trunk_h = t.size * t.trunk_ratio + draw_shadow_caster(t.pos + float3(0.0, 0.0, trunk_h * 0.5), + float3(0.11 * t.size, 0.11 * t.size, trunk_h)) + geo_cylinder |> draw_geometry_fragment() + for (ti in range(t.tiers)) { + let tf = float(ti) / float(max(1, t.tiers - 1)) + let cone_h = t.size * (1.2 - tf * 0.28) + let cone_r = t.size * (0.72 - tf * 0.18) + let cone_z = trunk_h * 0.72 + tf * (t.size * 0.58) + cone_h * 0.5 + draw_shadow_caster(t.pos + float3(0.0, 0.0, cone_z), float3(cone_r, cone_r, cone_h)) + geo_cone |> draw_geometry_fragment() + } } query() $(h : RiverHouse) { - // Project from near top of body so shadow extends past the building - // footprint instead of being eaten by the body it's under. - draw_shadow(h.pos + float3(0.0, 0.0, h.size * 1.0), - h.size * 0.85, h.size * 1.05, 0.5, false) + let body_h = h.size + draw_shadow_caster(h.pos + float3(0.0, 0.0, body_h * 0.5), + float3(h.size * 0.95, h.size * 1.1, body_h * 0.5)) + geo_cube |> draw_geometry_fragment() + draw_shadow_caster(h.pos + float3(0.0, 0.0, body_h + h.size * 0.25), + float3(h.size * 1.05, h.size * 1.18, h.size * 0.55)) + geo_prism |> draw_geometry_fragment() + } + +} + +// Obstacles standing in the water. +def private shadow_cast_obstacles() { + query() $(isle : Island) { + draw_shadow_caster(isle.pos, float3(isle.size, isle.size, isle.size * 0.35)) + geo_sphere |> draw_geometry_fragment() } query() $(d : FuelDepot) { - draw_shadow(d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.4), - FUEL_DEPOT_SIZE * 1.0, FUEL_DEPOT_SIZE * 1.8, 0.5, false) + draw_shadow_caster(d.pos, float3(FUEL_DEPOT_SIZE, FUEL_DEPOT_SIZE * 1.8, FUEL_DEPOT_SIZE * 0.25)) + geo_cube |> draw_geometry_fragment() + draw_shadow_caster(d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.9), + float3(0.14, 0.14, FUEL_DEPOT_SIZE * 0.9)) + geo_cube |> draw_geometry_fragment() } query() $(br : Bridge) { let pylon_h = BRIDGE_SPAN_Z + 0.5 - draw_pillar_shadow(br.left_x, br.pos.y, pylon_h, 0.22, 0.55) - draw_pillar_shadow(br.right_x, br.pos.y, pylon_h, 0.22, 0.55) + for (px in fixed_array(br.left_x, br.right_x)) { + draw_shadow_caster(float3(px, br.pos.y, pylon_h * 0.5), float3(0.4, 0.3, pylon_h)) + geo_cube |> draw_geometry_fragment() + } let gap_left = br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 let gap_right = br.gap_center_x + BRIDGE_GAP_WIDTH * 0.5 let lw = gap_left - br.left_x if (lw > 0.1) { - let cx = (br.left_x + gap_left) * 0.5 - draw_shadow(float3(cx, br.pos.y, BRIDGE_SPAN_Z), lw * 0.5, 0.35, 0.45, false) + draw_shadow_caster(float3((br.left_x + gap_left) * 0.5, br.pos.y, BRIDGE_SPAN_Z), + float3(lw * 0.5, 0.35, 0.25)) + geo_cube |> draw_geometry_fragment() } let rw = br.right_x - gap_right if (rw > 0.1) { - let cx = (gap_right + br.right_x) * 0.5 - draw_shadow(float3(cx, br.pos.y, BRIDGE_SPAN_Z), rw * 0.5, 0.35, 0.45, false) + draw_shadow_caster(float3((gap_right + br.right_x) * 0.5, br.pos.y, BRIDGE_SPAN_Z), + float3(rw * 0.5, 0.35, 0.25)) + geo_cube |> draw_geometry_fragment() } } +} - glDepthMask(true) - glDisable(GL_BLEND) +def render_shadow_casters() { + glUseProgram(shadow_program) + active_program = shadow_program + render_river_shadow() + shadow_cast_craft() + shadow_cast_scenery() + shadow_cast_obstacles() } +// --- Opaque scene --- + def render_player(game_time : float) { if (player_respawn_timer > PLAYER_RESPAWN_BLINK) { return @@ -1392,313 +1444,315 @@ def render_player(game_time : float) { return } let inv = player_invuln_timer > 0.0 - let pulse = (inv ? 0.7 + 0.3 * abs(sin(game_time * 8.0)) : 1.0) - let body_color = (inv ? float3(pulse, pulse * 0.8, 0.2) : float3(0.7, 0.85, 1.0)) + let pulse = (inv ? 0.75 + 0.35 * abs(sin(game_time * 8.0)) : 1.0) - glUseProgram(phong_program) - active_program = phong_program - - // Body sphere - draw_with_phong(player_pos, float3(PLAYER_SIZE), body_color) - geo_sphere |> draw_geometry_fragment() + use_prop_program() + player_palette(inv, pulse) + draw_prop(player_pos, float3(PLAYER_SIZE * 1.85), float3(1.0), player_orientation(), MAT_HULL) + geo_heli |> draw_geometry_fragment() + clear_model_palette() } -def render_enemies() { - glUseProgram(phong_program) - active_program = phong_program +def render_enemies(game_time : float) { + use_prop_program() - // Boats: elongated hull + small turret query() $(b : EnemyBoat) { - let hull_color = float3(0.6, 0.35, 0.15) - draw_with_phong(b.pos, float3(BOAT_SIZE * 0.6, BOAT_SIZE * 1.6, BOAT_SIZE * 0.5), hull_color) - geo_cube |> draw_geometry_fragment() - // Turret - let turret_pos = b.pos + float3(0.0, 0.0, BOAT_SIZE * 0.5) - draw_with_phong(turret_pos, float3(BOAT_SIZE * 0.3), float3(0.4, 0.2, 0.1)) - geo_cube |> draw_geometry_fragment() + boat_palette() + // Heel the hull into its patrol direction and let it ride the swell. + let heel = quat_y_rot(b.patrol_dir * 0.14) + let bob = quat_x_rot(sin(game_time * 1.7 + b.pos.x) * 0.05) + draw_prop(b.pos, float3(BOAT_SIZE * 1.25), float3(1.0), quat_mul(heel, bob), MAT_HULL) + geo_gunboat |> draw_geometry_fragment() } - // Planes: sphere fuselage + wing cubes query() $(p : EnemyPlane) { - let body_color = float3(0.5, 0.5, 0.7) - draw_with_phong(p.pos, float3(PLANE_SIZE * 0.6), body_color) - geo_sphere |> draw_geometry_fragment() - // Wings - let wing_l = p.pos + float3(-PLANE_SIZE * 1.0, 0.0, 0.0) - let wing_r = p.pos + float3(PLANE_SIZE * 1.0, 0.0, 0.0) - draw_with_phong(wing_l, float3(PLANE_SIZE * 0.9, PLANE_SIZE * 0.25, PLANE_SIZE * 0.1), float3(0.45, 0.45, 0.65)) - geo_cube |> draw_geometry_fragment() - draw_with_phong(wing_r, float3(PLANE_SIZE * 0.9, PLANE_SIZE * 0.25, PLANE_SIZE * 0.1), float3(0.45, 0.45, 0.65)) - geo_cube |> draw_geometry_fragment() + jet_palette() + let bank = quat_y_rot(clamp(p.vel.x * 0.10, -0.5, 0.5)) + draw_prop(p.pos, float3(PLANE_SIZE * 1.35), float3(1.0), bank, MAT_METAL) + geo_jet |> draw_geometry_fragment() } - // Enemy helis: sphere + rotor query() $(h : EnemyHelicopter) { - let enemy_color = float3(0.8, 0.25, 0.15) - draw_with_phong(h.pos, float3(ENEMY_HELI_SIZE), enemy_color) - geo_sphere |> draw_geometry_fragment() - // Rotor — transparent cross + enemy_heli_palette() + let drift = quat_y_rot(clamp((h.target_x - h.pos.x) * 0.06, -0.45, 0.45)) + draw_prop(h.pos, float3(ENEMY_HELI_SIZE * 1.7), float3(1.0), drift, MAT_HULL) + geo_heli |> draw_geometry_fragment() } + + clear_model_palette() } def render_obstacles(game_time : float) { - glDisable(GL_CULL_FACE) - glUseProgram(phong_program) - active_program = phong_program + use_prop_program() render_bridges() render_islands() render_fuel_depots(game_time) render_trees() render_houses() render_bonuses(game_time) - glEnable(GL_CULL_FACE) } -// Bridges: two pylons + two half-spans +// Bridges: two pylons + two half-spans, with a deck lip so the gap reads. def render_bridges() { + let pylon_color = float3(0.42, 0.40, 0.37) + let span_color = float3(0.50, 0.47, 0.43) query() $(br : Bridge) { let pylon_h = BRIDGE_SPAN_Z + 0.5 - let pylon_color = float3(0.55, 0.5, 0.45) - let span_color = float3(0.6, 0.55, 0.5) - - // Left pylon - let lp = float3(br.left_x, br.pos.y, pylon_h * 0.5) - draw_with_phong(lp, float3(0.4, 0.3, pylon_h), pylon_color) - geo_cube |> draw_geometry_fragment() - - // Right pylon - let rp = float3(br.right_x, br.pos.y, pylon_h * 0.5) - draw_with_phong(rp, float3(0.4, 0.3, pylon_h), pylon_color) - geo_cube |> draw_geometry_fragment() - - // Left half-span (from left pylon to gap left edge) - let gap_left = br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 - let left_span_cx = (br.left_x + gap_left) * 0.5 - let left_span_w = gap_left - br.left_x - if (left_span_w > 0.1) { - let sp = float3(left_span_cx, br.pos.y, BRIDGE_SPAN_Z) - draw_with_phong(sp, float3(left_span_w * 0.5, 0.35, 0.25), span_color) + for (px in fixed_array(br.left_x, br.right_x)) { + draw_prop(float3(px, br.pos.y, pylon_h * 0.5), float3(0.4, 0.3, pylon_h), + pylon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) geo_cube |> draw_geometry_fragment() } - // Right half-span (from gap right edge to right pylon) + let gap_left = br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 let gap_right = br.gap_center_x + BRIDGE_GAP_WIDTH * 0.5 - let right_span_cx = (gap_right + br.right_x) * 0.5 - let right_span_w = br.right_x - gap_right - if (right_span_w > 0.1) { - let sp = float3(right_span_cx, br.pos.y, BRIDGE_SPAN_Z) - draw_with_phong(sp, float3(right_span_w * 0.5, 0.35, 0.25), span_color) + // Damage reads as the deck darkening and losing its highlight. + let wear = saturate(float(br.health) / float(BRIDGE_HEALTH_MAX)) + let deck = span_color * (0.55 + wear * 0.45) + + let lw = gap_left - br.left_x + if (lw > 0.1) { + let cx = (br.left_x + gap_left) * 0.5 + draw_prop(float3(cx, br.pos.y, BRIDGE_SPAN_Z), float3(lw * 0.5, 0.35, 0.25), + deck, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + draw_prop(float3(cx, br.pos.y - 0.36, BRIDGE_SPAN_Z + 0.22), + float3(lw * 0.5, 0.05, 0.12), deck * 1.2, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + } + let rw = br.right_x - gap_right + if (rw > 0.1) { + let cx = (gap_right + br.right_x) * 0.5 + draw_prop(float3(cx, br.pos.y, BRIDGE_SPAN_Z), float3(rw * 0.5, 0.35, 0.25), + deck, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + draw_prop(float3(cx, br.pos.y - 0.36, BRIDGE_SPAN_Z + 0.22), + float3(rw * 0.5, 0.05, 0.12), deck * 1.2, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) geo_cube |> draw_geometry_fragment() } } } -// Islands: flattened sphere +// Islands: a flattened dome, tinted with the section's bank colour so a rock in +// the ash channel is not the same green as one in the forest. def render_islands() { query() $(isle : Island) { - let island_color = float3(0.3, 0.45, 0.2) - draw_with_phong(isle.pos, float3(isle.size, isle.size, isle.size * 0.3), island_color) + let island_color = get_bank_color() * 1.15 + float3(0.04) + draw_prop(isle.pos, float3(isle.size, isle.size, isle.size * 0.35), island_color, + float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) geo_sphere |> draw_geometry_fragment() } } -// Fuel depots: flat barge + tall beacon +// Fuel depots: barge, tank, and a beacon that goes green while refuelling. def render_fuel_depots(game_time : float) { query() $(d : FuelDepot) { let rr = FUEL_DEPOT_SIZE + PLAYER_SIZE + REFUEL_RADIUS let refueling = dist2d_sq(d.pos, player_pos) < rr * rr - let barge_color = float3(0.5, 0.5, 0.5) - let beacon_pulse = 0.6 + 0.4 * abs(sin(game_time * 3.5)) - let beacon_color = ( - refueling ? float3(0.2, beacon_pulse, 0.2) : float3(beacon_pulse, 0.8, 0.2) - ) - // Barge - draw_with_phong(d.pos, float3(FUEL_DEPOT_SIZE, FUEL_DEPOT_SIZE * 1.8, FUEL_DEPOT_SIZE * 0.25), barge_color) + let beacon_pulse = 0.55 + 0.45 * abs(sin(game_time * 3.5)) + + draw_prop(d.pos, float3(FUEL_DEPOT_SIZE, FUEL_DEPOT_SIZE * 1.8, FUEL_DEPOT_SIZE * 0.25), + float3(0.30, 0.31, 0.33), float4(0.0, 0.0, 0.0, 1.0), MAT_METAL) geo_cube |> draw_geometry_fragment() - // Beacon pole - let beacon_pos = d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.8) - draw_with_phong(beacon_pos, float3(0.1, 0.1, FUEL_DEPOT_SIZE * 0.8), beacon_color) + + // Storage tank lying on the deck; the cylinder gives the depot a + // silhouette you can pick out of the bank clutter at range. + draw_prop(d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.45), + float3(FUEL_DEPOT_SIZE * 0.42, FUEL_DEPOT_SIZE * 0.42, FUEL_DEPOT_SIZE * 0.95), + float3(0.62, 0.58, 0.20), quat_x_rot(PI * 0.5), MAT_METAL) + geo_cylinder |> draw_geometry_fragment() + + let beacon_color = ( + refueling ? float3(0.25, 1.0, 0.35) : float3(1.0, 0.72, 0.18) + ) * beacon_pulse + draw_prop(d.pos + float3(0.0, FUEL_DEPOT_SIZE * 0.9, FUEL_DEPOT_SIZE * 0.9), + float3(0.09, 0.09, FUEL_DEPOT_SIZE * 0.55), float3(0.22, 0.22, 0.24), + float4(0.0, 0.0, 0.0, 1.0), MAT_METAL) geo_cube |> draw_geometry_fragment() + draw_prop(d.pos + float3(0.0, FUEL_DEPOT_SIZE * 0.9, FUEL_DEPOT_SIZE * 1.5), + float3(0.16), beacon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_GLOW) + geo_sphere |> draw_geometry_fragment() } } -// Pine trees: trunk + stacked cones +// Pine trees: trunk + stacked cones, tinted by the section's foliage colour. def render_trees() { + let tint = env_now.foliage_tint query() $(t : RiverTree) { let trunk_h = t.size * t.trunk_ratio let trunk_pos = t.pos + float3(0.0, 0.0, trunk_h * 0.5) - let trunk_col = float3(0.24 + t.green_shift * 0.25, 0.18, 0.1) - draw_with_phong(trunk_pos, float3(0.11 * t.size, 0.11 * t.size, trunk_h), trunk_col) + let trunk_col = float3(0.22 + t.green_shift * 0.18, 0.15, 0.09) * tint + draw_prop(trunk_pos, float3(0.11 * t.size, 0.11 * t.size, trunk_h), trunk_col, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) geo_cylinder |> draw_geometry_fragment() let tier_den = max(1, t.tiers - 1) - let pine_dark = float3(0.07, 0.3, 0.08) - let pine_light = float3(0.16, 0.52, 0.16) + let pine_dark = float3(0.05, 0.22, 0.07) + let pine_light = float3(0.13, 0.42, 0.14) for (ti in range(t.tiers)) { let tf = float(ti) / float(tier_den) let cone_h = t.size * (1.2 - tf * 0.28) let cone_r = t.size * (0.72 - tf * 0.18) let cone_z = trunk_h * 0.72 + tf * (t.size * 0.58) + cone_h * 0.5 - let base_green = pine_dark * (1.0 - t.green_tint) + pine_light * t.green_tint - let cone_color = base_green + float3(t.green_shift * 0.35, t.green_shift * 0.1 - tf * 0.03, -t.green_shift * 0.2) - draw_with_phong(t.pos + float3(0.0, 0.0, cone_z), float3(cone_r, cone_r, cone_h), cone_color) + let base_green = lerp(pine_dark, pine_light, t.green_tint) + let cone_color = (base_green + float3(t.green_shift * 0.28, + t.green_shift * 0.08 - tf * 0.02, -t.green_shift * 0.14)) * tint + draw_prop(t.pos + float3(0.0, 0.0, cone_z), float3(cone_r, cone_r, cone_h), + cone_color, float4(0.0, 0.0, 0.0, 1.0), MAT_FOLIAGE) geo_cone |> draw_geometry_fragment() } } } -// Houses: cube body + prism roof. +// Houses: body + prism roof + a chimney, so the roofline is not a bare wedge. def render_houses() { query() $(h : RiverHouse) { - let body_color_a = float3(0.75, 0.68, 0.58) - let body_color_b = float3(0.6, 0.67, 0.73) - let roof_color_a = float3(0.62, 0.23, 0.2) - let roof_color_b = float3(0.24, 0.28, 0.34) - let body_color = body_color_a * (1.0 - h.body_tint) + body_color_b * h.body_tint - let roof_color = roof_color_a * (1.0 - h.roof_tint) + roof_color_b * h.roof_tint - - let body_h = h.size * 1.0 - let body_pos = h.pos + float3(0.0, 0.0, body_h * 0.5) - draw_with_phong(body_pos, float3(h.size * 0.95, h.size * 1.1, body_h * 0.5), body_color) + let body_color = lerp(float3(0.68, 0.62, 0.53), float3(0.52, 0.58, 0.64), h.body_tint) + let roof_color = lerp(float3(0.52, 0.19, 0.16), float3(0.20, 0.23, 0.29), h.roof_tint) + + let body_h = h.size + draw_prop(h.pos + float3(0.0, 0.0, body_h * 0.5), + float3(h.size * 0.95, h.size * 1.1, body_h * 0.5), body_color, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) geo_cube |> draw_geometry_fragment() let roof_h = h.size * 0.55 - let roof_pos = h.pos + float3(0.0, 0.0, body_h + roof_h * 0.45) - draw_with_phong(roof_pos, float3(h.size * 1.05, h.size * 1.18, roof_h), roof_color) + draw_prop(h.pos + float3(0.0, 0.0, body_h + roof_h * 0.45), + float3(h.size * 1.05, h.size * 1.18, roof_h), roof_color, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) geo_prism |> draw_geometry_fragment() + + draw_prop(h.pos + float3(h.size * 0.42, h.size * 0.3, body_h + roof_h * 0.9), + float3(h.size * 0.12, h.size * 0.12, roof_h * 0.6), roof_color * 0.7, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) + geo_cube |> draw_geometry_fragment() } } -// Bonuses: spinning horizontal cylinders, color-coded by type. +// Bonuses: a spinning canister with a bright core, so a pickup is visible over +// dark water without relying on the HUD. def render_bonuses(game_time : float) { let q_tilt = quat_y_rot(PI * 0.5) query() $(b : BonusPickup) { let bob = sin(game_time * BONUS_BOB_SPEED + b.bob_phase) * BONUS_BOB_AMPLITUDE - let p = b.pos + float3(0.0, 0.0, 0.55 + bob) + let p = b.pos + float3(0.0, 0.0, 0.72 + bob) let color = bonus_color(b.bonus_type) let spin = quat_z_rot(game_time * BONUS_SPIN_SPEED + b.bob_phase) let rot = quat_mul(spin, q_tilt) - draw_with_phong(p, float3(0.22, 0.22, 0.55), color, rot) + draw_prop(p, float3(0.24, 0.24, 0.52), color, rot, MAT_GLOW) geo_cylinder |> draw_geometry_fragment() + draw_prop(p, float3(0.30), color * 1.4, rot, MAT_GLOW) + geo_sphere |> draw_geometry_fragment() } } -def render_projectiles() { - // Flat pass for particles and trails (drawn before rotors) - glUseProgram(flat_program) - active_program = flat_program - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glDepthMask(false) +// --- Transparent scene --- + +def render_projectiles(game_time : float) { + use_unlit_program() - // Explosion chunks: transparent and spinning + // Explosion chunks: transparent, spinning, fading to embers. query() $(p : Particle) { let is_trail = p.max_life <= TRAIL_LIFETIME + 0.01 if (is_trail) { return ; } - let alpha = p.lifetime / p.max_life - let spin_t = 1.0 - alpha - let s = max(p.size * (0.45 + alpha * 0.8), 0.03) + let heat = p.lifetime / p.max_life + let spin_t = 1.0 - heat + // Debris shrinks and cools as it flies: white-hot at the flash, then + // the pickup/explosion tint, fading out. Without the cooling ramp the + // chunks read as flat orange cardboard. + let s = max(p.size * (0.20 + heat * 0.42), 0.02) let rot = quat_y_rot(p.spin_phase + p.spin_speed * spin_t) - v_model = compose(p.pos, rot, float3(s)) - f_Color = float4(p.color, alpha * 0.72) - vs_flat_bind_uniform(active_program) - fs_flat_bind_uniform(active_program) + let hot = lerp(p.color, float3(1.0, 0.94, 0.78), heat * heat) + draw_unlit(p.pos, float3(s), hot, heat * heat * 0.85, rot, heat * heat * 3.2) geo_cube |> draw_geometry_fragment() } - // Trail particles (blend based on remaining life) query() $(p : Particle) { let is_trail = p.max_life <= TRAIL_LIFETIME + 0.01 if (!is_trail) { return ; } let alpha = p.lifetime / p.max_life - let s = max(p.size, BULLET_SIZE * 1.0) - v_model = compose(p.pos, float4(0.0, 0.0, 0.0, 1.0), float3(s)) - f_Color = float4(p.color, alpha * 0.7) - vs_flat_bind_uniform(active_program) - fs_flat_bind_uniform(active_program) + let s = max(p.size, BULLET_SIZE) + draw_unlit(p.pos, float3(s), p.color, alpha * 0.6, float4(0.0, 0.0, 0.0, 1.0), 0.4) geo_sphere |> draw_geometry_fragment() } - // Player bullets: thin cyan cylinder elongated along velocity - glUseProgram(phong_program) - active_program = phong_program + // Player tracers: a bright core with a longer, dimmer streak behind it. query() $(b : PlayerBullet) { let dir = normalize(b.vel) let rot = quat_align_z(dir) - draw_with_phong(b.pos, float3(BULLET_SIZE, BULLET_SIZE, BULLET_SIZE * 3.5), float3(0.2, 1.0, 0.9), rot) + draw_unlit(b.pos, float3(BULLET_SIZE * 0.9, BULLET_SIZE * 0.9, BULLET_SIZE * 3.6), + float3(0.35, 1.0, 0.92), 1.0, rot, 2.4) + geo_cylinder |> draw_geometry_fragment() + draw_unlit(b.pos - dir * BULLET_SIZE * 4.0, + float3(BULLET_SIZE * 0.55, BULLET_SIZE * 0.55, BULLET_SIZE * 5.0), + float3(0.18, 0.75, 0.85), 0.35, rot, 1.0) geo_cylinder |> draw_geometry_fragment() } - // Enemy bullets: type-dependent shape + color query() $(b : EnemyBullet) { + let flicker = 0.8 + 0.2 * sin(game_time * 30.0 + b.pos.x) if (b.bullet_type == 0) { - // Boat: small yellowish sphere - draw_with_phong(b.pos, float3(ENEMY_BULLET_SIZE * 1.2), float3(0.9, 0.7, 0.2)) + draw_unlit(b.pos, float3(ENEMY_BULLET_SIZE * 0.85), float3(1.0, 0.80, 0.28), + 1.0, float4(0.0, 0.0, 0.0, 1.0), 1.6 * flicker) geo_sphere |> draw_geometry_fragment() - } elif (b.bullet_type == 1) { - // Heli: bright red thin cylinder - let dir = normalize(b.vel) - let rot = quat_align_z(dir) - draw_with_phong(b.pos, float3(ENEMY_BULLET_SIZE, ENEMY_BULLET_SIZE, ENEMY_BULLET_SIZE * 3.5), float3(1.0, 0.08, 0.08), rot) - geo_cylinder |> draw_geometry_fragment() } else { - // Plane: orange thin cylinder let dir = normalize(b.vel) let rot = quat_align_z(dir) - draw_with_phong(b.pos, float3(ENEMY_BULLET_SIZE, ENEMY_BULLET_SIZE, ENEMY_BULLET_SIZE * 3.5), float3(1.0, 0.5, 0.05), rot) + let tint = (b.bullet_type == 1 ? float3(1.0, 0.16, 0.12) : float3(1.0, 0.55, 0.10)) + draw_unlit(b.pos, float3(ENEMY_BULLET_SIZE * 0.5, ENEMY_BULLET_SIZE * 0.5, + ENEMY_BULLET_SIZE * 2.6), tint, 1.0, rot, 1.6 * flicker) geo_cylinder |> draw_geometry_fragment() } } +} - glDepthMask(true) - glDisable(GL_BLEND) +def private draw_disc(center : float3; radius : float; rot : float4; color : float3; alpha, phase : float) { + v_model = compose(center, rot, float3(radius, radius, radius)) + f_Color = float4(color, alpha) + f_Material = float4(1.0, 0.0, phase, 0.0) + vs_rotor_bind_uniform(active_program) + fs_rotor_bind_uniform(active_program) + geo_disc |> draw_geometry_fragment() } +// Rotor discs. One alpha-blended disc per aircraft, with the blade phase driven +// through the material's emissive slot so the shader can smear it. def render_rotors(game_time : float) { glDisable(GL_CULL_FACE) - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glDepthMask(false) glUseProgram(rotor_program) active_program = rotor_program - // Player rotor if (player_respawn_timer <= PLAYER_RESPAWN_BLINK) { let blinking = player_respawn_timer > 0.0 if (!blinking || int(game_time * 16.0) % 2 != 0) { - let rotor_angle = game_time * 12.0 - let rotor_rot = quat_y_rot(rotor_angle) - let rotor_pos = player_pos + float3(0.0, 0.0, PLAYER_SIZE * 0.8) - v_model = compose(rotor_pos, rotor_rot, float3(PLAYER_ROTOR_SIZE, 0.08, PLAYER_ROTOR_SIZE)) - f_Color = float4(0.9, 0.9, 0.95, 0.9) - vs_rotor_bind_uniform(active_program) - fs_rotor_bind_uniform(active_program) - geo_plane_xz |> draw_geometry_fragment() + let orient = player_orientation() + let up_offset = PLAYER_SIZE * 1.85 * 0.70 + draw_disc(player_pos + float3(0.0, 0.0, up_offset), PLAYER_ROTOR_SIZE * 1.05, + orient, float3(0.82, 0.88, 0.96), 0.55, game_time) + // Tail rotor, spinning in the vertical plane on the fin. + draw_disc(player_pos + float3(-PLAYER_SIZE * 0.20, -PLAYER_SIZE * 2.10, up_offset * 0.62), + PLAYER_ROTOR_SIZE * 0.34, quat_mul(orient, quat_y_rot(PI * 0.5)), + float3(0.78, 0.84, 0.92), 0.45, game_time * 2.4) } } - // Enemy helicopter rotors query() $(h : EnemyHelicopter) { - let rotor_angle = game_time * 15.0 + h.hover_phase - let rotor_rot = quat_y_rot(rotor_angle) - let rotor_pos = h.pos + float3(0.0, 0.0, ENEMY_HELI_SIZE * 0.8) - v_model = compose(rotor_pos, rotor_rot, float3(PLAYER_ROTOR_SIZE * 0.85, 0.06, PLAYER_ROTOR_SIZE * 0.85)) - f_Color = float4(0.7, 0.3, 0.2, 0.85) - vs_rotor_bind_uniform(active_program) - fs_rotor_bind_uniform(active_program) - geo_plane_xz |> draw_geometry_fragment() - } - - glDepthMask(true) - glDisable(GL_BLEND) + let up_offset = ENEMY_HELI_SIZE * 1.7 * 0.70 + draw_disc(h.pos + float3(0.0, 0.0, up_offset), PLAYER_ROTOR_SIZE * 0.92, + float4(0.0, 0.0, 0.0, 1.0), float3(0.85, 0.42, 0.32), 0.5, + game_time + h.hover_phase) + } + glEnable(GL_CULL_FACE) } def render_all(game_time : float) { - render_shadows(game_time) render_player(game_time) - render_enemies() + render_enemies(game_time) render_obstacles(game_time) - render_projectiles() +} + +def render_effects(game_time : float) { + render_projectiles(game_time) render_rotors(game_time) } diff --git a/examples/games/river_run/hud.das b/examples/games/river_run/hud.das index d987265e53..d22cb867b5 100644 --- a/examples/games/river_run/hud.das +++ b/examples/games/river_run/hud.das @@ -1,65 +1,178 @@ options gen2 options persistent_heap +options indenting = 4 require rr_globals public -def draw_text(text : string; x, y : int; tint : float3 = float3(1.0)) { +// Text layer. Everything is positioned in DESIGN pixels against a 1280x720 +// reference and scaled by hud_scale(), so the type keeps its proportions on a +// HiDPI framebuffer instead of shrinking to half size. +// +// Every string is drawn with a dark outline first. Over bright water or a lit +// sky, plain glyphs lose their edges entirely; the outline costs four extra +// quad draws and makes the HUD readable on any background. + +let TEXT_TITLE = 1.65 +let TEXT_HEAD = 0.90 +let TEXT_BODY = 0.62 +let TEXT_SMALL = 0.50 + +let OUTLINE_COLOR = float3(0.02, 0.03, 0.05) + +def private text_mvp(x, y, scale : float) : float4x4 { + let projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -1.0, 1.0) + let model = compose(float3(x, y, 0.0), float4(0.0, 0.0, 0.0, 1.0), float3(scale, scale, 1.0)) + return projection * model +} + +def private text_width(quads : array; scale : float) : float { + let dim = quads_dim(quads) + return (dim.vmax.x - dim.vmin.x) * scale +} + +// Draw one string at a design-space position with an outline. `scale` is a +// multiplier on the design text size; hud_scale() is applied on top. +def draw_text(text : string; x, y : float; size : float = TEXT_BODY; + tint : float3 = float3(1.0); outline : bool = true) { if (hud_font == null) { return } var quads <- (*hud_font) |> create_quads(text) - (*hud_font) |> draw_quads_2d(quads, display_w, display_h, x, y, tint) + let s = hud_scale() * size + let px = x * hud_scale() + let py = y * hud_scale() + if (outline) { + let o = max(hud_scale() * size * 1.1, 1.0) + for (d in fixed_array(float2(-1.0, 0.0), float2(1.0, 0.0), float2(0.0, -1.0), + float2(0.0, 1.0), float2(-0.7, -0.7), float2(0.7, 0.7))) { + (*hud_font) |> draw_quads(quads, text_mvp(px + d.x * o, py + d.y * o, s), OUTLINE_COLOR) + } + } + (*hud_font) |> draw_quads(quads, text_mvp(px, py, s), tint) delete quads } -def draw_text_centered(text : string; cx, y : int; tint : float3 = float3(1.0)) { +def draw_text_centered(text : string; cx, y : float; size : float = TEXT_BODY; + tint : float3 = float3(1.0)) { if (hud_font == null) { return } var quads <- (*hud_font) |> create_quads(text) - let dim = quads_dim(quads) - let tw = int(dim.vmax.x - dim.vmin.x) - let x = cx - tw / 2 - (*hud_font) |> draw_quads_2d(quads, display_w, display_h, x, y, tint) + let w = text_width(quads, size) delete quads + draw_text(text, cx - w * 0.5, y, size, tint) +} + +def private design_w() : float { + return float(display_w) / hud_scale() +} + +def private design_h() : float { + return float(display_h) / hud_scale() +} + +// A dimming plate behind a full-screen message, so titles never fight the +// scene behind them. +def private draw_scrim(alpha : float) { + use_unlit_program() + v_projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -100.0, 100.0) + v_view = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glDisable(GL_DEPTH_TEST) + draw_unlit( + float3(float(display_w) * 0.5, float(display_h) * 0.5, 0.0), + float3(float(display_w) * 0.5, float(display_h) * 0.5, 1.0), + float3(0.01, 0.015, 0.03), alpha + ) + geo_cube |> draw_geometry_fragment() +} + +def private draw_menu() { + let cx = design_w() * 0.5 + let cy = design_h() * 0.5 + draw_scrim(0.55) + draw_text_centered("RIVER RUN", cx, cy - 150.0, TEXT_TITLE, float3(0.55, 0.92, 1.0)) + draw_text_centered("fly the canyon · shoot what shoots back · do not run dry", + cx, cy - 92.0, TEXT_SMALL, float3(0.62, 0.70, 0.78)) + + draw_text_centered("ARROWS / A D steer", cx, cy - 30.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + draw_text_centered("W S throttle", cx, cy + 2.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + draw_text_centered("SPACE fire", cx, cy + 34.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + draw_text_centered("ESC pause", cx, cy + 66.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + + let blink = 0.55 + 0.45 * abs(sin(get_uptime() * 2.6)) + draw_text_centered("PRESS SPACE TO START", cx, cy + 128.0, TEXT_HEAD, + float3(1.0, 0.92, 0.45) * blink) +} + +def private draw_playing_readout() { + let w = design_w() + // Score sits inboard of the top-right life panel and clear of the progress + // rail, which is what the old centred readout kept colliding with. + draw_text("SCORE", 28.0, 32.0, TEXT_SMALL, float3(0.52, 0.62, 0.72)) + draw_text("{score}", 28.0, 62.0, TEXT_HEAD, float3(1.0, 1.0, 1.0)) + + let display_section = min(current_section + 1, MAX_SECTIONS) + draw_text_centered("SECTION {display_section} / {MAX_SECTIONS}", w * 0.5, 30.0, + TEXT_SMALL, float3(0.60, 0.72, 0.80)) + draw_text_centered("{env_now.name}", w * 0.5, 54.0, TEXT_BODY, float3(0.62, 0.86, 0.78)) +} + +def private draw_section_banner() { + if (section_banner_timer <= 0.0) { + return + } + let cx = design_w() * 0.5 + let cy = design_h() * 0.42 + // Fade in over the first quarter second, hold, then fade out. + let t = section_banner_timer / WAVE_BANNER_DURATION + let alpha = min(min((1.0 - t) * 6.0, 1.0), min(t * 2.2, 1.0)) + let display_section = min(current_section + 1, MAX_SECTIONS) + draw_text_centered("SECTION {display_section}", cx, cy, TEXT_TITLE * 0.75, + float3(1.0, 0.94, 0.55) * alpha) + draw_text_centered("{env_now.name}", cx, cy + 46.0, TEXT_HEAD, + float3(0.70, 0.88, 0.95) * alpha) +} + +def private draw_overlay_card(title : string; title_color : float3; sub, hint : string) { + let cx = design_w() * 0.5 + let cy = design_h() * 0.5 + draw_scrim(0.62) + draw_text_centered(title, cx, cy - 70.0, TEXT_TITLE, title_color) + if (sub != "") { + draw_text_centered(sub, cx, cy + 6.0, TEXT_HEAD, float3(0.92, 0.95, 1.0)) + } + let blink = 0.55 + 0.45 * abs(sin(get_uptime() * 2.6)) + draw_text_centered(hint, cx, cy + 62.0, TEXT_BODY, float3(0.80, 0.86, 0.92) * blink) } def draw_hud() { - let cx = display_w / 2 - let cy = display_h / 2 + if (hud_font == null) { + return + } + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glDisable(GL_DEPTH_TEST) if (game_state == GameState.menu) { - draw_text_centered("RIVER RUN", cx, cy - 80, float3(0.3, 0.9, 1.0)) - draw_text_centered("Use arrows (or A/D) to steer", cx, cy - 30, float3(0.8, 0.8, 0.8)) - draw_text_centered("SPACE to fire", cx, cy, float3(0.8, 0.8, 0.8)) - draw_text_centered("ESC to pause", cx, cy + 28, float3(0.8, 0.8, 0.8)) - draw_text_centered("PRESS SPACE TO START", cx, cy + 74, float3(0.9, 1.0, 0.5)) + draw_menu() + glEnable(GL_DEPTH_TEST) return } - // In-game HUD: SCORE + SECTION number remain as text; - // lives / fuel / active powerups are drawn as 3D HUD geometry in hud3d.das. - let display_section = min(current_section + 1, MAX_SECTIONS) - draw_text_centered("SCORE: {score}", cx, 18, float3(1.0)) - draw_text_centered("SECTION: {display_section}", cx, 44, float3(0.6, 1.0, 0.7)) - - // Section banner - if (section_banner_timer > 0.0) { - let alpha = min(section_banner_timer, 1.0) - draw_text_centered("SECTION {display_section}", cx, cy - 12, - float3(0.9, 1.0, 0.4) * alpha) - } + draw_playing_readout() + draw_section_banner() if (game_state == GameState.paused) { - draw_text_centered("PAUSED", cx, cy - 20, float3(1.0, 0.9, 0.3)) - draw_text_centered("PRESS ESC TO RESUME", cx, cy + 16, float3(0.8, 0.8, 0.8)) + draw_overlay_card("PAUSED", float3(1.0, 0.88, 0.35), "", "PRESS ESC TO RESUME") } elif (game_state == GameState.game_over_state) { - draw_text_centered("GAME OVER", cx, cy - 24, float3(1.0, 0.25, 0.25)) - draw_text_centered("SCORE: {score}", cx, cy + 8, float3(1.0)) - draw_text_centered("PRESS SPACE TO RESTART", cx, cy + 40, float3(0.8, 0.8, 0.8)) + draw_overlay_card("GAME OVER", float3(1.0, 0.32, 0.28), "SCORE {score}", + "PRESS SPACE TO RESTART") } elif (game_state == GameState.win_state) { - draw_text_centered("YOU WIN!", cx, cy - 28, float3(0.4, 1.0, 0.5)) - draw_text_centered("SCORE: {score}", cx, cy + 8, float3(1.0)) - draw_text_centered("PRESS SPACE TO RESTART", cx, cy + 40, float3(0.8, 0.8, 0.8)) + draw_overlay_card("RIVER RUN COMPLETE", float3(0.45, 1.0, 0.58), "SCORE {score}", + "PRESS SPACE TO RESTART") } + + glEnable(GL_DEPTH_TEST) } diff --git a/examples/games/river_run/hud3d.das b/examples/games/river_run/hud3d.das index 832a587f8c..78dec7071f 100644 --- a/examples/games/river_run/hud3d.das +++ b/examples/games/river_run/hud3d.das @@ -1,176 +1,328 @@ options gen2 options persistent_heap +options indenting = 4 require rr_globals public require gameplay -// Layout constants +// The instrument layer: fuel gauge, throttle, life icons, powerup timers and +// the section progress bar, all drawn as flat 2D geometry in a screen-space +// orthographic pass on top of the tone-mapped frame. +// +// Every constant below is in DESIGN pixels against a 1280x720 reference and is +// multiplied by hud_scale() at draw time. That is the fix for the old HUD, +// which used raw framebuffer pixels and so came out half size on any HiDPI +// display. -let LIFE_ICON_SIZE = 18.0 -let LIFE_ICON_GAP = 14.0 -let LIFE_ICON_RIGHT_PAD = 30.0 -let LIFE_ICON_TOP = 38.0 +let PANEL_MARGIN = 26.0 +let PANEL_PAD = 12.0 -let FUEL_BAR_X = 24.0 -let FUEL_BAR_Y = 28.0 -let FUEL_BAR_W = 220.0 -let FUEL_BAR_H = 18.0 -let FUEL_BAR_PAD = 2.0 +let GAUGE_W = 240.0 +let GAUGE_H = 16.0 +let GAUGE_LABEL_H = 15.0 -let PWR_ICON_X = 56.0 -let PWR_ICON_Y0 = 78.0 -let PWR_ROW_H = 56.0 -let PWR_ICON_RADIUS = 16.0 -let PWR_RING_RADIUS = 28.0 -let PWR_RING_TICKS = 8 -let PWR_TICK_SIZE = 3.5 +let THROTTLE_W = 240.0 +let THROTTLE_H = 8.0 -let LIFE_BODY_COLOR = float3(0.7, 0.85, 1.0) +let LIFE_ICON_R = 13.0 +let LIFE_ICON_GAP = 12.0 + +let PWR_ICON_R = 15.0 +let PWR_RING_R = 23.0 +let PWR_RING_TICKS = 16 +let PWR_ROW_H = 58.0 + +let PROGRESS_H = 3.0 + +let COLOR_PANEL = float3(0.03, 0.045, 0.075) +let COLOR_FRAME = float3(0.34, 0.46, 0.58) +let COLOR_DIM = float3(0.16, 0.21, 0.27) +let COLOR_ACCENT = float3(0.35, 0.86, 1.0) def hud_set_uniforms() { - let near = -100.0 - let far = 100.0 - v_projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, near, far) + v_projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -100.0, 100.0) v_view = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) } -def draw_lives_3d(game_time : float) { - if (player_lives <= 0) { - return - } - let cy = LIFE_ICON_TOP - let step = LIFE_ICON_SIZE * 2.0 + LIFE_ICON_GAP +// A screen-space axis-aligned rectangle, given in design pixels. +def hud_rect(x, y, w, h : float; color : float3; alpha : float = 1.0; emissive : float = 0.0) { + let s = hud_scale() + draw_unlit( + float3((x + w * 0.5) * s, (y + h * 0.5) * s, 0.0), + float3(w * 0.5 * s, h * 0.5 * s, 1.0), + color, alpha, float4(0.0, 0.0, 0.0, 1.0), emissive + ) + geo_cube |> draw_geometry_fragment() +} - // Bodies (phong-shaded spheres) - glUseProgram(phong_program) - active_program = phong_program - for (i in range(player_lives)) { - let cx = float(display_w) - LIFE_ICON_RIGHT_PAD - float(i) * step - LIFE_ICON_SIZE - draw_with_phong(float3(cx, cy, 0.0), float3(LIFE_ICON_SIZE), LIFE_BODY_COLOR) - geo_sphere |> draw_geometry_fragment() +def hud_disc(cx, cy, r : float; color : float3; alpha : float = 1.0; emissive : float = 0.0) { + let s = hud_scale() + draw_unlit( + float3(cx * s, cy * s, 0.0), float3(r * s, r * s, 1.0), + color, alpha, float4(0.0, 0.0, 0.0, 1.0), emissive + ) + geo_disc |> draw_geometry_fragment() +} + +// A framed panel: dark plate, a hairline border, and a bright inset on the top +// edge so the whole HUD reads as one instrument cluster rather than loose bars. +def hud_panel(x, y, w, h : float; alpha : float = 0.72) { + hud_rect(x, y, w, h, COLOR_PANEL, alpha) + hud_rect(x, y, w, 1.5, COLOR_FRAME, alpha * 0.9) + hud_rect(x, y + h - 1.5, w, 1.5, COLOR_FRAME, alpha * 0.5) + hud_rect(x, y, 1.5, h, COLOR_FRAME, alpha * 0.5) + hud_rect(x + w - 1.5, y, 1.5, h, COLOR_FRAME, alpha * 0.5) +} + +def design_width() : float { + return float(display_w) / hud_scale() +} + +def design_height() : float { + return float(display_h) / hud_scale() +} + +// --- Fuel + throttle cluster (bottom left) --- + +def private draw_fuel_gauge(x, y : float) { + let frac = clamp(player_fuel / PLAYER_FUEL_MAX, 0.0, 1.0) + let color = fuel_bar_color() + + hud_rect(x, y, GAUGE_W, GAUGE_H, COLOR_DIM, 0.9) + if (frac > 0.001) { + hud_rect(x + 1.5, y + 1.5, (GAUGE_W - 3.0) * frac, GAUGE_H - 3.0, color, 1.0, 0.55) + // A brighter cap at the leading edge gives the bar a readable head as + // it drains, which a flat fill does not. + let head_x = x + 1.5 + max((GAUGE_W - 3.0) * frac - 3.0, 0.0) + hud_rect(head_x, y + 1.5, 3.0, GAUGE_H - 3.0, color * 1.6 + float3(0.25), 1.0, 1.4) + } + // Quarter ticks, so the pilot can read "half tank" without counting pixels. + for (i in range(1, 4)) { + let tx = x + GAUGE_W * float(i) * 0.25 + hud_rect(tx - 0.75, y, 1.5, GAUGE_H, COLOR_PANEL, 0.85) } +} - // Rotors (alpha-blended cross, spinning in screen XY plane) - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glDepthMask(false) - glUseProgram(rotor_program) - active_program = rotor_program - let face_camera = quat_x_rot(PI * 0.5) - let rotor_radius = LIFE_ICON_SIZE * 1.55 - let rotor_z = LIFE_ICON_SIZE * 0.6 - for (i in range(player_lives)) { - let cx = float(display_w) - LIFE_ICON_RIGHT_PAD - float(i) * step - LIFE_ICON_SIZE - let angle = game_time * 12.0 + float(i) * 0.7 - let spin = quat_z_rot(angle) - let rot = quat_mul(spin, face_camera) - v_model = compose(float3(cx, cy, rotor_z), rot, float3(rotor_radius, 0.5, rotor_radius)) - f_Color = float4(0.9, 0.9, 0.95, 0.9) - vs_rotor_bind_uniform(active_program) - fs_rotor_bind_uniform(active_program) - geo_plane_xz |> draw_geometry_fragment() +def private draw_throttle(x, y : float) { + let speed_max = PLAYER_FWD_SPEED_MAX * section_speed_mult() + let den = max(speed_max - PLAYER_FWD_SPEED_MIN, 0.001) + let frac = clamp((player_fwd_speed - PLAYER_FWD_SPEED_MIN) / den, 0.0, 1.0) + hud_rect(x, y, THROTTLE_W, THROTTLE_H, COLOR_DIM, 0.9) + if (frac > 0.001) { + hud_rect(x + 1.0, y + 1.0, (THROTTLE_W - 2.0) * frac, THROTTLE_H - 2.0, + lerp(COLOR_ACCENT, float3(1.0, 0.85, 0.35), frac), 1.0, 0.4) } - glDepthMask(true) - glDisable(GL_BLEND) } -def draw_fuel_bar_3d() { - glUseProgram(flat_program) - active_program = flat_program +def draw_status_cluster() { + let base_y = design_height() - PANEL_MARGIN - (GAUGE_LABEL_H + GAUGE_H + THROTTLE_H + PANEL_PAD * 2.0 + 6.0) + let panel_w = GAUGE_W + PANEL_PAD * 2.0 + let panel_h = GAUGE_LABEL_H + GAUGE_H + THROTTLE_H + PANEL_PAD * 2.0 + 6.0 + hud_panel(PANEL_MARGIN, base_y, panel_w, panel_h) - // Frame - let frame_cx = FUEL_BAR_X + FUEL_BAR_W * 0.5 - let frame_cy = FUEL_BAR_Y + FUEL_BAR_H * 0.5 - let frame_color = float3(0.05, 0.06, 0.10) - draw_with_flat(float3(frame_cx, frame_cy, -0.5), float3(FUEL_BAR_W * 0.5, FUEL_BAR_H * 0.5, 0.5), frame_color) - geo_cube |> draw_geometry_fragment() + let inner_x = PANEL_MARGIN + PANEL_PAD + draw_fuel_gauge(inner_x, base_y + PANEL_PAD + GAUGE_LABEL_H) + draw_throttle(inner_x, base_y + PANEL_PAD + GAUGE_LABEL_H + GAUGE_H + 6.0) +} - // Fill - let fill_max_w = FUEL_BAR_W - 2.0 * FUEL_BAR_PAD - let fill_w = fill_max_w * clamp(player_fuel / PLAYER_FUEL_MAX, 0.0, 1.0) - if (fill_w > 0.001) { - let fill_x = FUEL_BAR_X + FUEL_BAR_PAD + fill_w * 0.5 - let fill_h = FUEL_BAR_H - 2.0 * FUEL_BAR_PAD - draw_with_flat(float3(fill_x, frame_cy, 0.5), float3(fill_w * 0.5, fill_h * 0.5, 0.5), fuel_bar_color()) - geo_cube |> draw_geometry_fragment() +// --- Lives (top right) --- +// +// The life counter draws the player's own silhouette rather than an abstract +// pip, so the icon and the thing it stands for match. + +def draw_lives() { + if (player_lives <= 0) { + return + } + let s = hud_scale() + let step = LIFE_ICON_R * 2.0 + LIFE_ICON_GAP + let top = PANEL_MARGIN + LIFE_ICON_R + 4.0 + let panel_w = float(player_lives) * step + PANEL_PAD * 2.0 - LIFE_ICON_GAP + hud_panel(design_width() - PANEL_MARGIN - panel_w, PANEL_MARGIN, + panel_w, LIFE_ICON_R * 2.0 + PANEL_PAD * 1.5) + + for (i in range(player_lives)) { + let cx = design_width() - PANEL_MARGIN - PANEL_PAD - LIFE_ICON_R - float(i) * step + hud_disc(cx, top, LIFE_ICON_R + 2.0, COLOR_FRAME, 0.35) + // Nose-up plan view of the helicopter, scaled to the icon radius. + draw_prop( + float3(cx * s, top * s, 0.0), + float3(LIFE_ICON_R * 0.92 * s), + float3(1.0), + quat_mul(quat_z_rot(PI), quat_x_rot(-PI * 0.5)), + MAT_HULL + ) + geo_heli |> draw_geometry_fragment() } } -def draw_powerup_icon_3d(game_time : float; cx, cy : float; t : BonusType; timer, max_timer : float) { - let color = bonus_color(t) - let q_tilt = quat_y_rot(PI * 0.5) - let spin = quat_z_rot(game_time * BONUS_SPIN_SPEED) - let rot = quat_mul(spin, q_tilt) - - // Spinning cylinder (phong) - glUseProgram(phong_program) - active_program = phong_program - draw_with_phong( - float3(cx, cy, 0.5), - float3(PWR_ICON_RADIUS * 0.42, PWR_ICON_RADIUS * 0.42, PWR_ICON_RADIUS * 1.05), - color, - rot - ) - geo_cylinder |> draw_geometry_fragment() +// --- Powerup timers (left, under the status cluster) --- - // Tick ring (flat) - glUseProgram(flat_program) - active_program = flat_program +def private draw_powerup(cx, cy : float; t : BonusType; timer, max_timer : float) { + let color = bonus_color(t) let frac = clamp(timer / max(max_timer, 0.001), 0.0, 1.0) - let lit_count = int(frac * float(PWR_RING_TICKS) + 0.99) + + hud_disc(cx, cy, PWR_ICON_R, COLOR_PANEL, 0.85) + hud_disc(cx, cy, PWR_ICON_R - 3.0, color, 0.95, 0.7) + + // A ring of ticks reading counter-clockwise from the top as the timer runs + // down -- readable at a glance without a number. + let lit = int(frac * float(PWR_RING_TICKS) + 0.999) for (i in range(PWR_RING_TICKS)) { let angle = float(i) * (2.0 * PI / float(PWR_RING_TICKS)) - PI * 0.5 - let tx = cx + cos(angle) * PWR_RING_RADIUS - let ty = cy + sin(angle) * PWR_RING_RADIUS - let lit = i < lit_count - let tick_col = (lit ? color : color * 0.22) - draw_with_flat( - float3(tx, ty, -0.25), - float3(PWR_TICK_SIZE), - tick_col - ) - geo_cube |> draw_geometry_fragment() + let tx = cx + cos(angle) * PWR_RING_R + let ty = cy + sin(angle) * PWR_RING_R + let on = i < lit + hud_disc(tx, ty, (on ? 2.6 : 1.8), (on ? color : COLOR_DIM), 1.0, (on ? 0.8 : 0.0)) + } + + // The last two seconds flash, so an expiring powerup is impossible to miss. + if (timer < 2.0 && int(get_uptime() * 8.0) % 2 == 0) { + hud_disc(cx, cy, PWR_RING_R + 4.0, color, 0.22, 0.5) } } -def draw_powerups_3d(game_time : float) { +def draw_powerups() { + let base_y = design_height() - PANEL_MARGIN - 150.0 var row = 0 if (player_multishot_timer > 0.0) { - let cy = PWR_ICON_Y0 + float(row) * PWR_ROW_H - draw_powerup_icon_3d(game_time, PWR_ICON_X, cy, BonusType.multishot, - player_multishot_timer, BONUS_MULTISHOT_TIME) + draw_powerup(PANEL_MARGIN + PWR_RING_R + 6.0, base_y - float(row) * PWR_ROW_H, + BonusType.multishot, player_multishot_timer, BONUS_MULTISHOT_TIME) row++ } if (player_fastshot_timer > 0.0) { - let cy = PWR_ICON_Y0 + float(row) * PWR_ROW_H - draw_powerup_icon_3d(game_time, PWR_ICON_X, cy, BonusType.fastshot, - player_fastshot_timer, BONUS_FASTSHOT_TIME) + draw_powerup(PANEL_MARGIN + PWR_RING_R + 6.0, base_y - float(row) * PWR_ROW_H, + BonusType.fastshot, player_fastshot_timer, BONUS_FASTSHOT_TIME) row++ } } -def draw_hud_3d(game_time : float) { - if (game_state == GameState.menu) { +// --- Section progress (a thin rail across the top of the screen) --- + +def draw_progress_rail() { + let w = design_width() + let frac = clamp(section_dist / SECTION_LENGTH, 0.0, 1.0) + hud_rect(0.0, 0.0, w, PROGRESS_H, COLOR_PANEL, 0.45) + hud_rect(0.0, 0.0, w * frac, PROGRESS_H, COLOR_ACCENT, 0.95, 0.6) + + // Section boundaries as notches, so the rail also shows how far into the + // run you are overall. + for (i in range(1, MAX_SECTIONS)) { + let tx = w * float(i) / float(MAX_SECTIONS) + let passed = i <= current_section + hud_rect(tx - 1.0, 0.0, 2.0, PROGRESS_H + 2.0, + (passed ? COLOR_ACCENT : COLOR_DIM), 0.75) + } +} + +// --- Low fuel warning bar --- + +def draw_low_fuel_warning() { + if (player_fuel >= LOW_FUEL_THRESHOLD || game_state != GameState.playing) { return } + let pulse = 0.35 + 0.35 * abs(sin(get_uptime() * 6.0)) + let h = design_height() + let w = design_width() + // A vignette-like band top and bottom rather than a full-screen wash, so + // the warning never obscures the water the player is threading. + hud_rect(0.0, 0.0, w, 26.0, float3(1.0, 0.15, 0.1), pulse * 0.5, 0.6) + hud_rect(0.0, h - 26.0, w, 26.0, float3(1.0, 0.15, 0.1), pulse * 0.5, 0.6) +} - // Save 3D state - let saved_proj = v_projection - let saved_view = v_view +// The scene shaders fog by view distance, and in the screen-space ortho pass +// "view distance" is a pixel coordinate -- hundreds of units, which fogs every +// panel and scrim to solid haze. The whole overlay therefore runs with fog +// switched off; the caller owns the save/restore because the text layer draws +// its own geometry too. +def begin_hud_overlay() : float3 { let saved_fog = f_FogParams + f_FogParams = float3(0.0, 0.0, 0.0) + return saved_fog +} + +def end_hud_overlay(saved_fog : float3) { + f_FogParams = saved_fog +} +def draw_hud_3d(_game_time : float) { + let saved_proj = v_projection + let saved_view = v_view hud_set_uniforms() - f_FogParams = float2(1.0e9, 1.0e9 + 1.0) + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - // Fresh depth space for HUD geometry — sorts correctly among itself. - glClear(GL_DEPTH_BUFFER_BIT) - glEnable(GL_DEPTH_TEST) + if (game_state != GameState.menu) { + use_unlit_program() + draw_progress_rail() + draw_status_cluster() + draw_powerups() + draw_low_fuel_warning() - draw_fuel_bar_3d() - draw_powerups_3d(game_time) - draw_lives_3d(game_time) + // Life icons are lit geometry under a fixed palette, so the counter + // stays legible at night and in the ash channel. + if (player_lives > 0) { + use_prop_program() + let saved_light = push_hud_light() + player_hud_palette() + draw_lives() + clear_model_palette() + pop_hud_light(saved_light) + } + } + // Culling and depth stay off: the text layer draws next, and its quads have + // no reliable winding. + glEnable(GL_DEPTH_TEST) v_projection = saved_proj v_view = saved_view - f_FogParams = saved_fog +} + +// A fixed, sunlit palette for the HUD helicopters so they stay legible at +// night and in the ash channel. +def private player_hud_palette() { + set_model_palette( + float3(0.78, 0.86, 0.94), + float3(0.20, 0.38, 0.48), + float3(0.58, 0.62, 0.66), + float3(0.12, 0.14, 0.16) + ) +} + +// The life icons are lit geometry drawn in screen space, where the world sun +// direction means nothing -- at night it left them unlit and unreadable. Swap +// in a fixed three-quarter key for the icon draw and put the scene's lighting +// back afterwards. +struct SavedLight { + sun_dir : float3 + sun_color : float3 + sky_color : float3 + horizon_color : float3 + bounce_color : float3 +} + +def private push_hud_light() : SavedLight { + let saved = SavedLight( + sun_dir = f_SunDir, + sun_color = f_SunColor, + sky_color = f_SkyColor, + horizon_color = f_HorizonColor, + bounce_color = f_BounceColor + ) + f_SunDir = normalize(float3(-0.45, -0.55, 0.70)) + f_SunColor = float3(1.15, 1.18, 1.25) + f_SkyColor = float3(0.26, 0.30, 0.38) + f_HorizonColor = float3(0.30, 0.34, 0.40) + f_BounceColor = float3(0.10, 0.12, 0.16) + return saved +} + +def private pop_hud_light(saved : SavedLight) { + f_SunDir = saved.sun_dir + f_SunColor = saved.sun_color + f_SkyColor = saved.sky_color + f_HorizonColor = saved.horizon_color + f_BounceColor = saved.bounce_color } diff --git a/examples/games/river_run/main.das b/examples/games/river_run/main.das index 9509013942..ceee532634 100644 --- a/examples/games/river_run/main.das +++ b/examples/games/river_run/main.das @@ -8,23 +8,25 @@ require river require gameplay require hud require hud3d +require rr_live require daslib/strings_convert // --- GL Setup --- def create_gl_objects() { - flat_program = cache_shader_program(vs_flat`shader_text, fs_flat`shader_text) - phong_program = cache_shader_program(vs_phong`shader_text, fs_phong`shader_text) - river_program = cache_shader_program(vs_river`shader_text, fs_river`shader_text) - rotor_program = cache_shader_program(vs_rotor`shader_text, fs_rotor`shader_text) - grass_program = cache_shader_program(vs_grass`shader_text, fs_grass`shader_text) + create_scene_programs() + create_postfx_programs() - geo_sphere <- create_geometry_fragment <| gen_sphere(10, 7, false) + geo_sphere <- create_geometry_fragment <| gen_sphere(14, 10, false) geo_cube <- create_geometry_fragment <| gen_cube() geo_plane_xz <- create_geometry_fragment <| gen_plane(GenDirection.xz) - geo_cylinder <- create_geometry_fragment <| gen_cylinder(GenDirection.xy, 8) - geo_cone <- create_geometry_fragment <| gen_cone(GenDirection.xy, 10) + geo_cylinder <- create_geometry_fragment <| gen_cylinder(GenDirection.xy, 12) + geo_cone <- create_geometry_fragment <| gen_cone(GenDirection.xy, 12) geo_prism <- create_geometry_fragment <| gen_prism(GenDirection.xy) + geo_disc <- create_geometry_fragment <| gen_disc(24) + geo_heli <- create_geometry_fragment <| gen_helicopter() + geo_gunboat <- create_geometry_fragment <| gen_gunboat() + geo_jet <- create_geometry_fragment <| gen_jet() cache_ttf_objects() hud_font = cache_font("{get_das_root()}/modules/dasStbImage/fonts/droidsansmono.ttf") @@ -32,7 +34,17 @@ def create_gl_objects() { // --- Camera --- -def update_camera() { +var cam_eye = float3(0.0, 0.0, 10.0) +var cam_right = float3(1.0, 0.0, 0.0) +var cam_up = float3(0.0, 0.0, 1.0) +var cam_fwd = float3(0.0, 1.0, 0.0) +var cam_scale = float2(1.0, 1.0) + +let CAM_FOV_DEG = 50.0 +let Z_NEAR = 0.1 +let Z_FAR = 420.0 + +def update_camera(aspect : float) { // Camera lags behind player X cam_x += (player_pos.x - cam_x) * CAM_X_LAG @@ -46,10 +58,99 @@ def update_camera() { let t = get_uptime() let shake = float3(sin(t * 47.0), sin(t * 53.0), sin(t * 61.0)) * screen_shake_amount - let eye = float3(cam_x, player_pos.y - CAM_BACK, cam_height) + shake + cam_eye = float3(cam_x, player_pos.y - CAM_BACK, cam_height) + shake let center = float3(player_pos.x, player_pos.y + CAM_LOOK_AHEAD, 0.0) + let world_up = float3(0.0, 0.0, 1.0) + v_view = look_at_rh(cam_eye, center, world_up) + + // The sky pass is attributeless, so it rebuilds view rays from this basis + // rather than from an inverse view-projection. + cam_fwd = normalize(center - cam_eye) + cam_right = normalize(cross(cam_fwd, world_up)) + cam_up = cross(cam_right, cam_fwd) + let tan_half = tan(CAM_FOV_DEG * 0.5 * PI / 180.0) + cam_scale = float2(tan_half * aspect, tan_half) +} + +// --- Lighting environment --- +// +// One ortho cascade tracking the play area ahead of the player. The box is wide +// enough to hold both banks and long enough to cover everything the camera can +// see, which is all a game this shallow needs. + +let SHADOW_HALF_WIDTH = 34.0 +let SHADOW_AHEAD = 58.0 +let SHADOW_BEHIND = 22.0 +let SHADOW_DEPTH = 120.0 + +def build_light_matrix() { + let focus = float3(cam_x, player_pos.y + (SHADOW_AHEAD - SHADOW_BEHIND) * 0.5, 1.5) + let eye = focus + env_now.sun_dir * (SHADOW_DEPTH * 0.5) let up = float3(0.0, 0.0, 1.0) - v_view = look_at_rh(eye, center, up) + let half_len = (SHADOW_AHEAD + SHADOW_BEHIND) * 0.5 + let view = look_at_rh(eye, focus, up) + let proj = ortho_rh(-SHADOW_HALF_WIDTH, SHADOW_HALF_WIDTH, + -half_len, half_len, 0.1, SHADOW_DEPTH) + v_light_vp = proj * view +} + +def apply_environment(game_time : float) { + f_SunDir = env_now.sun_dir + f_SunColor = env_now.sun_color + f_SkyColor = env_now.sky_color + f_HorizonColor = env_now.horizon_color + f_BounceColor = env_now.bounce_color + f_FogColor = env_now.fog_color + f_FogParams = float3(env_now.fog_density, 0.018, 38.0) + f_NightAmount = env_now.night + f_CameraPos = cam_eye + f_GameTime = game_time + f_ZFar = Z_FAR + f_CamRight = cam_right + f_CamUp = cam_up + f_CamFwd = cam_fwd + f_CamScale = cam_scale + f_ShadowMap := shadow_tex + f_ShadowTexel = float2(1.0 / float(SHADOW_SIZE)) + f_PaletteOn = 0.0 +} + +// Where the sun lands on screen, for the shaft pass. Returns w <= 0 behind the +// camera, in which case the caller skips the whole scattering chain. +def sun_screen_uv() : float3 { + let sun_world = cam_eye + env_now.sun_dir * 900.0 + let clip = v_projection * (v_view * float4(sun_world, 1.0)) + if (clip.w <= 0.0) { + return float3(0.5, 0.5, 0.0) + } + let ndc = clip.xyz / clip.w + let uv = ndc.xy * 0.5 + float2(0.5) + // Let the shafts persist a little past the frame edge so the effect fades + // out as the sun leaves the view instead of popping off. + let on_screen = (uv.x > -0.45 && uv.x < 1.45 && uv.y > -0.45 && uv.y < 1.45 ? 1.0 : 0.0) + return float3(uv, on_screen) +} + +def build_post_settings(game_time : float) : PostSettings { + var cfg = PostSettings() + let sun_uv = sun_screen_uv() + cfg.exposure = env_now.exposure + cfg.bloom_strength = env_now.bloom + cfg.shaft_uv = sun_uv.xy + cfg.shaft_on_screen = sun_uv.z + cfg.shaft_strength = env_now.shafts + cfg.grade_tint = env_now.grade_tint + cfg.grade_amount = env_now.grade_amount + cfg.tan_half_fov = cam_scale + cfg.z_far = Z_FAR + cfg.time = game_time + // A hit briefly hot-grades the frame: more bloom, more grain, harder + // vignette. Cheap, and it sells the impact better than the shake alone. + let stress = saturate(screen_shake_amount * 0.9) + cfg.bloom_strength += stress * 0.5 + cfg.grain += stress * 0.05 + cfg.vignette_strength += stress * 0.25 + return cfg } // --- State Transitions --- @@ -105,33 +206,94 @@ def init() { score = 0 current_section = 0 cam_x = 0.0 + set_env_immediate(0) init_river() rebuild_river_geometry() river_dirty = false + } else { + refresh_env() } } -[export] -def update() { - if (!live_begin_frame()) { +def simulate() { + if (game_state != GameState.playing) { return } + update_section() + update_player(false) + commit() + if (game_state == GameState.playing) { + update_enemies() + process_collisions() + commit() + } + if (game_state == GameState.playing) { + cull_far_entities() + advance_river() + if (river_dirty) { + rebuild_river_geometry() + river_dirty = false + } + } +} - live_get_framebuffer_size(display_w, display_h) - glViewport(0, 0, display_w, display_h) +def render_frame(game_time : float) { + ensure_render_targets(display_w, display_h) + + build_light_matrix() + apply_environment(game_time) + + begin_shadow_pass() + render_shadow_casters() + end_shadow_pass() + + begin_scene_pass(env_now.horizon_color) - // Sky color: blend from section sky color to dark at horizon - let sky = get_river_color() * 0.15 + float3(0.03, 0.05, 0.12) - glClearColor(sky.x, sky.y, sky.z, 1.0) - glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + // Sky first, depth-writes off: it is a background, and every later surface + // must be able to draw over it. + glDepthMask(false) + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glUseProgram(sky_program) + active_program = sky_program + vs_sky_bind_uniform(sky_program) + fs_sky_bind_uniform(sky_program) + fullscreen_pass() glEnable(GL_DEPTH_TEST) + glDepthMask(true) glEnable(GL_CULL_FACE) glCullFace(GL_BACK) glFrontFace(GL_CCW) + render_river(game_time) + render_all(game_time) + + begin_transparent_span() + render_effects(game_time) + end_transparent_span() + + run_postfx(build_post_settings(game_time)) + + // HUD lands on the back buffer after tone mapping, so it keeps its designed + // colours instead of being graded with the scene. Culling stays off for the + // whole overlay -- glyph quads have no reliable winding. + glDisable(GL_CULL_FACE) + let saved_fog = begin_hud_overlay() + draw_hud_3d(game_time) + draw_hud() + end_hud_overlay(saved_fog) + glEnable(GL_CULL_FACE) +} + +[export] +def update() { + if (!live_begin_frame()) { + return + } + + live_get_framebuffer_size(display_w, display_h) let aspect = (display_h != 0 ? float(display_w) / float(display_h) : 1.0) - v_projection = perspective_rh_minus1_to_1(50.0 * PI / 180.0, aspect, 0.1, 500.0) - update_camera() + v_projection = perspective_rh_minus1_to_1(CAM_FOV_DEG * PI / 180.0, aspect, Z_NEAR, Z_FAR) let real_dt = get_dt() tick_dt = min(real_dt, 1.0 / 30.0) @@ -151,41 +313,14 @@ def update() { handle_menu_input() handle_pause_input() - - if (game_state == GameState.playing) { - update_section() - update_player(false) - commit() - if (game_state == GameState.playing) { - update_enemies() - process_collisions() - commit() - } - if (game_state == GameState.playing) { - cull_far_entities() - advance_river() - if (river_dirty) { - rebuild_river_geometry() - river_dirty = false - } - } - } + simulate() update_music_state() - update_engine_sound(game_state == GameState.playing, player_fwd_speed, PLAYER_FWD_SPEED_MAX * section_speed_mult()) - - // Fog uniforms — set once per frame before any render - f_FogColor = get_fog_color() - f_FogParams = float2(player_pos.y + 3.0, player_pos.y + 30.0) + update_engine_sound(game_state == GameState.playing, player_fwd_speed, + PLAYER_FWD_SPEED_MAX * section_speed_mult()) - // Render - render_river(game_time) - render_all(game_time) - - glDisable(GL_CULL_FACE) - draw_hud_3d(game_time) - draw_hud() - glEnable(GL_CULL_FACE) + update_camera(aspect) + render_frame(game_time) live_end_frame() } diff --git a/examples/games/river_run/river.das b/examples/games/river_run/river.das index 70ecb777b7..b1c795db3a 100644 --- a/examples/games/river_run/river.das +++ b/examples/games/river_run/river.das @@ -237,18 +237,87 @@ def is_out_of_river(pos : float3) : bool { return !(s.x <= s.y && pos.x >= s.x && pos.x <= s.y) } -// --- Geometry Rebuild --- +// --- Terrain profile --- +// +// The banks are no longer a flat plane. Height is a function of distance from +// the waterline: a wet beach, a shoulder, then rolling hills that carry the eye +// out to the fog. Gameplay shares the same function (terrain_height) so props +// sit on the ground instead of hovering over it. + +// Terrain reaches this far outward from each bank, far enough that the fog has +// swallowed the mesh edge before it can read as a horizon line. +let TERRAIN_EXTEND = 150.0 +// Lateral spans per bank. Distance is distributed quadratically, so the +// shoreline -- where the silhouette matters -- gets most of the vertices. +let TERRAIN_SPANS = 12 +let ISLAND_SPANS = 4 + +def private thash(p : float2) : float { + let h = sin(dot(p, float2(127.1, 311.7))) * 43758.545 + return h - floor(h) +} -// Terrain extends this far outward from each bank -let TERRAIN_EXTEND = 90.0 +def private tnoise(p : float2) : float { + let i = floor(p) + let f = p - i + let u = f * f * (float2(3.0) - 2.0 * f) + let a = thash(i) + let b = thash(i + float2(1.0, 0.0)) + let c = thash(i + float2(0.0, 1.0)) + let d = thash(i + float2(1.0, 1.0)) + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y) +} -// One quad, corners in (near-left, near-right, far-left, far-right) order, wound as two triangles. -def push_quad(var frag : GeometryFragment; p0, p1, p2, p3, nrm : float3; uv : float2) { +// Height above the waterline at `d` metres inland, sampled at world (wx, wy). +def bank_profile(d, wx, wy : float) : float { + if (d <= 0.0) { + return 0.0 + } + let p = float2(wx, wy) + // Wet sand lifting out of the water, then the bank shoulder. + var h = smoothstep(0.0, 2.6, d) * 0.42 + h += smoothstep(1.8, 11.0, d) * 1.75 + // Mid-scale dunes/mounds, then the far hills that build the skyline. + h += smoothstep(4.0, 26.0, d) * 1.9 * (tnoise(p * 0.055) - 0.35) + h += smoothstep(14.0, 95.0, d) * 11.0 * (tnoise(p * 0.017 + float2(19.0, 7.0)) - 0.30) + return max(h, 0.0) +} + +def private island_profile(d : float) : float { + if (d <= 0.0) { + return 0.0 + } + return smoothstep(0.0, 1.4, d) * 0.55 + smoothstep(0.6, 3.2, d) * 0.7 +} + +// Ground height at any world position: 0 on open water, the bank profile +// outside the channel, a low crown on the centre island of a split. +def terrain_height(wx, wy : float) : float { + let s = sample_river_segment(wy) + if (wx <= s.left_bank_x) { + return bank_profile(s.left_bank_x - wx, wx, wy) + } + if (wx >= s.right_bank_x) { + return bank_profile(wx - s.right_bank_x, wx, wy) + } + if (s.split && wx >= s.split_left_x && wx <= s.split_right_x) { + let d = min(wx - s.split_left_x, s.split_right_x - wx) + return island_profile(d) + } + return 0.0 +} + +// --- Geometry Rebuild --- + +def private push_strip_quad(var frag : GeometryFragment; p0, p1, p2, p3 : float3; uv0, uv1, uv2, uv3 : float2) { let base = length(frag.vertices) - frag.vertices |> push(GeometryPreviewVertex(xyz = p0, normal = nrm, uv = uv)) // nolint:STYLE012 - appends to the accumulating fragment - frag.vertices |> push(GeometryPreviewVertex(xyz = p1, normal = nrm, uv = uv)) - frag.vertices |> push(GeometryPreviewVertex(xyz = p2, normal = nrm, uv = uv)) - frag.vertices |> push(GeometryPreviewVertex(xyz = p3, normal = nrm, uv = uv)) + // Normals are filled in by recompute_normals once the whole strip exists; + // a per-quad face normal would facet the rolling hills. + let zero = float3(0.0) + frag.vertices |> push(GeometryPreviewVertex(xyz = p0, normal = zero, uv = uv0)) // nolint:STYLE012 - appends to the accumulating fragment + frag.vertices |> push(GeometryPreviewVertex(xyz = p1, normal = zero, uv = uv1)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p2, normal = zero, uv = uv2)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p3, normal = zero, uv = uv3)) frag.indices |> push(base + 0) // nolint:STYLE012 - appends to the accumulating fragment frag.indices |> push(base + 1) frag.indices |> push(base + 2) @@ -257,103 +326,175 @@ def push_quad(var frag : GeometryFragment; p0, p1, p2, p3, nrm : float3; uv : fl frag.indices |> push(base + 3) } +// Central-difference normal of the analytic height field. Cheaper and smoother +// than averaging face normals, and it matches what the gameplay code samples. +def private profile_normal(d, wx, wy, side : float) : float3 { + let e = 0.6 + let hx0 = bank_profile(max(d - e, 0.0), wx - side * e, wy) + let hx1 = bank_profile(d + e, wx + side * e, wy) + let hy0 = bank_profile(d, wx, wy - e) + let hy1 = bank_profile(d, wx, wy + e) + let dx = (hx1 - hx0) / (2.0 * e) * side + let dy = (hy1 - hy0) / (2.0 * e) + return normalize(float3(-dx, -dy, 1.0)) +} + +// Quadratic span distribution: dense at the waterline, sparse out in the haze. +def private span_dist(i : int; count : int; extent : float) : float { + let t = float(i) / float(count) + return extent * t * t +} + +def private emit_bank(var frag : GeometryFragment; y0, y1, edge0, edge1, side : float) { + for (i in range(TERRAIN_SPANS)) { + let d0 = span_dist(i, TERRAIN_SPANS, TERRAIN_EXTEND) + let d1 = span_dist(i + 1, TERRAIN_SPANS, TERRAIN_EXTEND) + let x00 = edge0 + side * d0 + let x01 = edge0 + side * d1 + let x10 = edge1 + side * d0 + let x11 = edge1 + side * d1 + let p00 = float3(x00, y0, bank_profile(d0, x00, y0)) + let p01 = float3(x01, y0, bank_profile(d1, x01, y0)) + let p10 = float3(x10, y1, bank_profile(d0, x10, y1)) + let p11 = float3(x11, y1, bank_profile(d1, x11, y1)) + // Winding depends on which side we are on, so both banks face up. + if (side > 0.0) { + push_strip_quad(frag, p00, p01, p10, p11, + float2(d0, y0), float2(d1, y0), float2(d0, y1), float2(d1, y1)) + } else { + push_strip_quad(frag, p01, p00, p11, p10, + float2(d1, y0), float2(d0, y0), float2(d1, y1), float2(d0, y1)) + } + } +} + +def private emit_island(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1 : float) { + let half0 = (r0 - l0) * 0.5 + let half1 = (r1 - l1) * 0.5 + let c0 = (l0 + r0) * 0.5 + let c1 = (l1 + r1) * 0.5 + for (i in range(ISLAND_SPANS)) { + let t0 = float(i) / float(ISLAND_SPANS) + let t1 = float(i + 1) / float(ISLAND_SPANS) + // Two mirrored halves so the crown runs down the middle of the island. + for (s in fixed_array(-1.0, 1.0)) { + let x00 = c0 + s * half0 * (1.0 - t0) + let x01 = c0 + s * half0 * (1.0 - t1) + let x10 = c1 + s * half1 * (1.0 - t0) + let x11 = c1 + s * half1 * (1.0 - t1) + let d0 = half0 * t0 + let d1 = half0 * t1 + let p00 = float3(x00, y0, island_profile(d0)) + let p01 = float3(x01, y0, island_profile(d1)) + let p10 = float3(x10, y1, island_profile(d0)) + let p11 = float3(x11, y1, island_profile(d1)) + if (s > 0.0) { + push_strip_quad(frag, p01, p00, p11, p10, + float2(d1, y0), float2(d0, y0), float2(d1, y1), float2(d0, y1)) + } else { + push_strip_quad(frag, p00, p01, p10, p11, + float2(d0, y0), float2(d1, y0), float2(d0, y1), float2(d1, y1)) + } + } + } +} + def gen_river_banks() : GeometryFragment { var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles let n = length(river_segments) if (n < 2) { - frag.prim = GeometryFragmentType.triangles gen_bbox(frag) return <- frag } - // We build triangle strips for left bank and right bank: - // Left bank: each segment pair produces a quad from (left_bank_x, y0) to (left_bank_x - extend, y1) - // Right bank: quad from (right_bank_x, y0) to (right_bank_x + extend, y1) - - frag.prim = GeometryFragmentType.triangles - let Z = 0.0 let slices = n - 1 - // Reserve for worst case where each slice includes left, right, and split-center strips. - frag.vertices |> reserve(slices * 12) - frag.indices |> reserve(slices * 18) + let per_slice = (TERRAIN_SPANS * 2 + ISLAND_SPANS * 2) + frag.vertices |> reserve(slices * per_slice * 4) + frag.indices |> reserve(slices * per_slice * 6) - let zero_n = float3(0.0, 0.0, 1.0) - let zero_uv = float2(0.0, 0.0) - - for (i in range(n - 1)) { + for (i in range(slices)) { let s0 = river_segments[i] let s1 = river_segments[i + 1] - let y0 = s0.world_y - let y1 = s1.world_y - - // Left bank quad: (lx0,y0) to (lx1,y1), extends leftward - let lx0 = s0.left_bank_x - let lx1 = s1.left_bank_x - let lext0 = lx0 - TERRAIN_EXTEND - let lext1 = lx1 - TERRAIN_EXTEND - - push_quad(frag, - float3(lext0, y0, Z), float3(lx0, y0, Z), - float3(lext1, y1, Z), float3(lx1, y1, Z), zero_n, zero_uv) - - // Right bank quad: extends rightward - let rx0 = s0.right_bank_x - let rx1 = s1.right_bank_x - let rext0 = rx0 + TERRAIN_EXTEND - let rext1 = rx1 + TERRAIN_EXTEND - - push_quad(frag, - float3(rx0, y0, Z), float3(rext0, y0, Z), - float3(rx1, y1, Z), float3(rext1, y1, Z), zero_n, zero_uv) - - // Center island strip when river is split at this slice. + emit_bank(frag, s0.world_y, s1.world_y, s0.left_bank_x, s1.left_bank_x, -1.0) + emit_bank(frag, s0.world_y, s1.world_y, s0.right_bank_x, s1.right_bank_x, 1.0) if (s0.split || s1.split) { - push_quad(frag, - float3(s0.split_left_x, y0, Z), float3(s0.split_right_x, y0, Z), - float3(s1.split_left_x, y1, Z), float3(s1.split_right_x, y1, Z), zero_n, zero_uv) + emit_island(frag, s0.world_y, s1.world_y, + s0.split_left_x, s0.split_right_x, s1.split_left_x, s1.split_right_x) } } + // Fill in the smooth normals now that every vertex position is final. + for (v in frag.vertices) { + let d = v.uv.x + let side = (v.xyz.x >= 0.0 ? 1.0 : -1.0) + v.normal = (v.xyz.z <= 0.0001 && d <= 0.0001 + ? float3(0.0, 0.0, 1.0) + : profile_normal(d, v.xyz.x, v.xyz.y, side)) + } + gen_bbox(frag) return <- frag } +// Water is subdivided laterally so uv.x can carry distance-to-nearest-bank; the +// shader turns that into the shallow-water tint and the foam band. +let WATER_SPANS = 10 + +def private emit_water_channel(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1, z : float) { + let w0 = r0 - l0 + let w1 = r1 - l1 + if (w0 <= 0.01 && w1 <= 0.01) { + return + } + for (i in range(WATER_SPANS)) { + let t0 = float(i) / float(WATER_SPANS) + let t1 = float(i + 1) / float(WATER_SPANS) + let x00 = l0 + w0 * t0 + let x01 = l0 + w0 * t1 + let x10 = l1 + w1 * t0 + let x11 = l1 + w1 * t1 + let d00 = min(x00 - l0, r0 - x00) + let d01 = min(x01 - l0, r0 - x01) + let d10 = min(x10 - l1, r1 - x10) + let d11 = min(x11 - l1, r1 - x11) + push_strip_quad(frag, + float3(x00, y0, z), float3(x01, y0, z), + float3(x10, y1, z), float3(x11, y1, z), + float2(d00, y0), float2(d01, y0), float2(d10, y1), float2(d11, y1)) + } +} + def gen_river_surface() : GeometryFragment { var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles let n = length(river_segments) if (n < 2) { - frag.prim = GeometryFragmentType.triangles gen_bbox(frag) return <- frag } - frag.prim = GeometryFragmentType.triangles - let Z = -0.02 - let zero_n = float3(0.0, 0.0, 1.0) - let zero_uv = float2(0.0, 0.0) + let slices = n - 1 + frag.vertices |> reserve(slices * WATER_SPANS * 8) + frag.indices |> reserve(slices * WATER_SPANS * 12) + let z = -0.03 - for (i in range(n - 1)) { + for (i in range(slices)) { let s0 = river_segments[i] let s1 = river_segments[i + 1] - if (s0.split || s1.split) { - // Left channel surface. - push_quad(frag, - float3(s0.left_bank_x, s0.world_y, Z), float3(s0.split_left_x, s0.world_y, Z), - float3(s1.left_bank_x, s1.world_y, Z), float3(s1.split_left_x, s1.world_y, Z), - zero_n, zero_uv) - - // Right channel surface. - push_quad(frag, - float3(s0.split_right_x, s0.world_y, Z), float3(s0.right_bank_x, s0.world_y, Z), - float3(s1.split_right_x, s1.world_y, Z), float3(s1.right_bank_x, s1.world_y, Z), - zero_n, zero_uv) + emit_water_channel(frag, s0.world_y, s1.world_y, + s0.left_bank_x, s0.split_left_x, s1.left_bank_x, s1.split_left_x, z) + emit_water_channel(frag, s0.world_y, s1.world_y, + s0.split_right_x, s0.right_bank_x, s1.split_right_x, s1.right_bank_x, z) } else { - push_quad(frag, - float3(s0.left_bank_x, s0.world_y, Z), float3(s0.right_bank_x, s0.world_y, Z), - float3(s1.left_bank_x, s1.world_y, Z), float3(s1.right_bank_x, s1.world_y, Z), - zero_n, zero_uv) + emit_water_channel(frag, s0.world_y, s1.world_y, + s0.left_bank_x, s0.right_bank_x, s1.left_bank_x, s1.right_bank_x, z) } } + for (v in frag.vertices) { + v.normal = float3(0.0, 0.0, 1.0) + } + gen_bbox(frag) return <- frag } @@ -368,23 +509,31 @@ def rebuild_river_geometry() { // --- Rendering --- def render_river(game_time : float) { - // River surface - glUseProgram(river_program) - active_program = river_program + glUseProgram(water_program) + active_program = water_program v_model = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) - let rc = get_river_color() - f_RiverColor = float4(rc, 1.0) + f_RiverColor = float4(get_river_color(), 1.0) f_GameTime = game_time - vs_river_bind_uniform(active_program) - fs_river_bind_uniform(active_program) + vs_water_bind_uniform(active_program) + fs_water_bind_uniform(active_program) river_surface_geo |> draw_geometry_fragment() - // Banks (grass shader, terrain color + per-pixel noise tint) - glUseProgram(grass_program) - active_program = grass_program + glUseProgram(terrain_program) + active_program = terrain_program + v_model = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + f_BankColor = float4(get_bank_color(), 1.0) + f_ShoreColor = env_now.shore_color + f_RockColor = env_now.rock_color + f_Material = float4(0.95, 0.02, 0.0, 0.10) + vs_terrain_bind_uniform(active_program) + fs_terrain_bind_uniform(active_program) + river_bank_geo |> draw_geometry_fragment() +} + +// Banks cast into the shadow map as well, so a hill throws shade across the +// water at a low sun. +def render_river_shadow() { v_model = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) - f_Color = float4(get_bank_color(), 1.0) - vs_grass_bind_uniform(active_program) - fs_grass_bind_uniform(active_program) + vs_shadow_bind_uniform(active_program) river_bank_geo |> draw_geometry_fragment() } diff --git a/examples/games/river_run/rr_audio.das b/examples/games/river_run/rr_audio.das index 8404a83671..62181bb230 100644 --- a/examples/games/river_run/rr_audio.das +++ b/examples/games/river_run/rr_audio.das @@ -19,12 +19,18 @@ var @live snd_bonus_life : array var @live snd_bonus_fastshot : array var @live snd_engine_loop : array -var @live engine_sid : SID = INVALID_SID - -var @live audio_initialized = false +// These three describe LIVE PROCESS state, not game state, so they must NOT +// persist across a reload. shutdown_audio() runs before the reload tears the +// context down; if the flags came back true, init_audio() would skip re-init and +// the new context would drive a destroyed audio system and a strudel Stream +// owned by the freed old heap -- which aborts in Stream::push on a dead mutex. +// `asch` is deliberately non-@live for the same reason; the flags must match it. +var engine_sid : SID = INVALID_SID + +var audio_initialized = false var asch : AudioSystemChannels -var @live music_initialized = false +var music_initialized = false var g_music_tracks : table var prev_music_state = GameState.menu var low_fuel_music_active = false diff --git a/examples/games/river_run/rr_globals.das b/examples/games/river_run/rr_globals.das index 486a614dc7..6aac6d92a4 100644 --- a/examples/games/river_run/rr_globals.das +++ b/examples/games/river_run/rr_globals.das @@ -7,10 +7,18 @@ require opengl/opengl_boost public require opengl/opengl_gen public require opengl/opengl_cache public require live/opengl_live public +require rr_shaders public +require rr_models public +require rr_postfx public require live/live_commands public require live/live_api public require live/live_watch_boost public require live/live_vars public +// Without decs_live every reload emptied the world -- the player kept flying +// down a river with no enemies, bridges or scenery, because init() correctly +// skips re-spawning under is_reload(). The templates are all POD, so they +// serialize cleanly. +require live/decs_live public require live/audio_live public require audio/audio_boost public require strudel/strudel public @@ -104,10 +112,13 @@ let PARTICLE_LIFETIME = 0.7 let TRAIL_LIFETIME = 0.12 let CAM_BACK = 14.0 -let CAM_LOOK_AHEAD = 8.2 +let CAM_LOOK_AHEAD = 11.0 let CAM_X_LAG = 0.08 -let CAM_PITCH_SLOW_DEG = 42.0 // pitch at PLAYER_FWD_SPEED_MIN -let CAM_PITCH_FAST_DEG = 36.0 // pitch at top speed (PLAYER_FWD_SPEED_MAX * section_speed_mult) +// Pitch is the camera's angle below horizontal. It has to stay under half the +// vertical FOV or the horizon never enters the frame -- which is why the old +// 42-degree framing showed nothing but ground. +let CAM_PITCH_SLOW_DEG = 19.0 // pitch at PLAYER_FWD_SPEED_MIN +let CAM_PITCH_FAST_DEG = 15.0 // pitch at top speed (PLAYER_FWD_SPEED_MAX * section_speed_mult) let SHAKE_SMALL = 0.20 let SHAKE_MED = 0.55 @@ -286,6 +297,7 @@ var @live section_dist = 0.0 var @live section_banner_timer = 0.0 var @live section_score_bonus_pending = false var @live restart_input_lock = 0.0 +var @live god_mode = false var @live screen_shake_amount = 0.0 var @live slow_mo_timer = 0.0 @@ -311,189 +323,219 @@ var display_h = 0 var @live low_fuel_beep_timer = 0.0 var @live refuel_sound_timer = 0.0 +// --- Section environments --- +// +// Each section is a whole lighting setup, not just a river tint: sun colour and +// elevation, sky and horizon, fog, exposure and grade. The look of the game +// therefore changes with progress without a single authored texture, and the +// post chain reads the same values, so bloom and shafts stay in key with the +// biome. + +struct SectionEnv { + sun_dir : float3 + sun_color : float3 + sky_color : float3 + horizon_color : float3 + bounce_color : float3 + fog_color : float3 + fog_density : float + river_color : float3 + bank_color : float3 + shore_color : float3 + rock_color : float3 + foliage_tint : float3 + night : float + exposure : float + bloom : float + shafts : float + grade_tint : float3 + grade_amount : float + name : string +} + +def private env_dawn() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(0.26, 0.95, 0.125)), + sun_color = float3(1.45, 1.16, 0.82), + sky_color = float3(0.22, 0.40, 0.78), + horizon_color = float3(0.92, 0.68, 0.46), + bounce_color = float3(0.10, 0.17, 0.26), + fog_color = float3(0.78, 0.72, 0.70), + fog_density = 0.0062, + river_color = float3(0.06, 0.24, 0.52), + bank_color = float3(0.26, 0.44, 0.20), + shore_color = float3(0.62, 0.55, 0.42), + rock_color = float3(0.34, 0.33, 0.32), + foliage_tint = float3(1.0, 0.97, 0.86), + night = 0.0, + exposure = 1.05, + bloom = 0.40, + shafts = 0.80, + grade_tint = float3(1.06, 0.98, 0.92), + grade_amount = 0.10, + name = "DELTA DAWN" + ) +} + +def private env_desert() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(0.20, 0.93, 0.30)), + sun_color = float3(1.72, 1.52, 1.18), + sky_color = float3(0.30, 0.50, 0.85), + horizon_color = float3(0.95, 0.83, 0.60), + bounce_color = float3(0.30, 0.24, 0.15), + fog_color = float3(0.88, 0.80, 0.62), + fog_density = 0.0080, + river_color = float3(0.10, 0.30, 0.44), + bank_color = float3(0.62, 0.50, 0.26), + shore_color = float3(0.80, 0.70, 0.46), + rock_color = float3(0.52, 0.42, 0.30), + foliage_tint = float3(1.0, 0.94, 0.72), + night = 0.0, + exposure = 0.92, + bloom = 0.34, + shafts = 0.55, + grade_tint = float3(1.10, 1.02, 0.84), + grade_amount = 0.16, + name = "SUN FLATS" + ) +} + +def private env_forest() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(-0.30, 0.92, 0.25)), + sun_color = float3(1.10, 1.16, 0.96), + sky_color = float3(0.42, 0.52, 0.58), + horizon_color = float3(0.66, 0.72, 0.64), + bounce_color = float3(0.12, 0.20, 0.13), + fog_color = float3(0.62, 0.70, 0.63), + fog_density = 0.0105, + river_color = float3(0.05, 0.22, 0.26), + bank_color = float3(0.16, 0.36, 0.14), + shore_color = float3(0.44, 0.42, 0.34), + rock_color = float3(0.28, 0.30, 0.28), + foliage_tint = float3(0.90, 1.0, 0.88), + night = 0.0, + exposure = 1.18, + bloom = 0.30, + shafts = 1.10, + grade_tint = float3(0.92, 1.02, 0.94), + grade_amount = 0.18, + name = "GREEN NARROWS" + ) +} + +def private env_night() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(-0.26, 0.94, 0.21)), + sun_color = float3(0.52, 0.64, 1.00), + sky_color = float3(0.026, 0.040, 0.090), + horizon_color = float3(0.12, 0.16, 0.30), + bounce_color = float3(0.055, 0.075, 0.130), + fog_color = float3(0.10, 0.13, 0.23), + fog_density = 0.0095, + river_color = float3(0.020, 0.055, 0.135), + // Moonlit ground still has to read as ground: at the first pass's values + // the banks went black and the player could not see the channel edge. + bank_color = float3(0.19, 0.24, 0.28), + shore_color = float3(0.30, 0.33, 0.38), + rock_color = float3(0.24, 0.26, 0.30), + foliage_tint = float3(0.60, 0.72, 0.92), + night = 1.0, + exposure = 1.40, + bloom = 0.72, + shafts = 0.35, + grade_tint = float3(0.80, 0.90, 1.18), + grade_amount = 0.26, + name = "MIDNIGHT RUN" + ) +} + +def private env_volcanic() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(0.32, 0.94, 0.115)), + sun_color = float3(1.95, 0.92, 0.44), + sky_color = float3(0.16, 0.09, 0.13), + horizon_color = float3(0.88, 0.34, 0.14), + bounce_color = float3(0.26, 0.09, 0.04), + fog_color = float3(0.55, 0.28, 0.19), + fog_density = 0.0120, + river_color = float3(0.16, 0.13, 0.16), + bank_color = float3(0.30, 0.15, 0.11), + shore_color = float3(0.24, 0.17, 0.15), + rock_color = float3(0.18, 0.14, 0.14), + foliage_tint = float3(1.0, 0.78, 0.62), + night = 0.35, + exposure = 1.10, + bloom = 0.66, + shafts = 1.35, + grade_tint = float3(1.18, 0.88, 0.76), + grade_amount = 0.28, + name = "ASH CHANNEL" + ) +} + +def section_env(idx : int) : SectionEnv { + let biome = clamp(idx, 0, MAX_SECTIONS - 1) % 5 + if (biome == 0) { + return <- env_dawn() + } elif (biome == 1) { + return <- env_desert() + } elif (biome == 2) { + return <- env_forest() + } elif (biome == 3) { + return <- env_night() + } + return <- env_volcanic() +} + +def lerp_env(a, b : SectionEnv; t : float) : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(lerp(a.sun_dir, b.sun_dir, t)), + sun_color = lerp(a.sun_color, b.sun_color, t), + sky_color = lerp(a.sky_color, b.sky_color, t), + horizon_color = lerp(a.horizon_color, b.horizon_color, t), + bounce_color = lerp(a.bounce_color, b.bounce_color, t), + fog_color = lerp(a.fog_color, b.fog_color, t), + fog_density = lerp(a.fog_density, b.fog_density, t), + river_color = lerp(a.river_color, b.river_color, t), + bank_color = lerp(a.bank_color, b.bank_color, t), + shore_color = lerp(a.shore_color, b.shore_color, t), + rock_color = lerp(a.rock_color, b.rock_color, t), + foliage_tint = lerp(a.foliage_tint, b.foliage_tint, t), + night = lerp(a.night, b.night, t), + exposure = lerp(a.exposure, b.exposure, t), + bloom = lerp(a.bloom, b.bloom, t), + shafts = lerp(a.shafts, b.shafts, t), + grade_tint = lerp(a.grade_tint, b.grade_tint, t), + grade_amount = lerp(a.grade_amount, b.grade_amount, t), + name = (t < 0.5 ? a.name : b.name) + ) +} + var @live section_color_blend_t = 1.0 -var @live river_color_from = float3(0.1, 0.4, 0.7) -var @live river_color_to = float3(0.1, 0.4, 0.7) -var @live bank_color_from = float3(0.25, 0.55, 0.2) -var @live bank_color_to = float3(0.25, 0.55, 0.2) - -var section_colors = fixed_array( - float3(0.1, 0.4, 0.7), - float3(0.7, 0.55, 0.2), - float3(0.1, 0.35, 0.1), - float3(0.2, 0.2, 0.3), - float3(0.6, 0.15, 0.05), - float3(0.1, 0.4, 0.7), - float3(0.7, 0.55, 0.2), - float3(0.1, 0.35, 0.1), - float3(0.2, 0.2, 0.3), - float3(0.6, 0.15, 0.05) -) - -var bank_colors = fixed_array( - float3(0.25, 0.55, 0.2), - float3(0.65, 0.5, 0.15), - float3(0.15, 0.4, 0.1), - float3(0.3, 0.3, 0.4), - float3(0.4, 0.1, 0.05), - float3(0.25, 0.55, 0.2), - float3(0.65, 0.5, 0.15), - float3(0.15, 0.4, 0.1), - float3(0.3, 0.3, 0.4), - float3(0.4, 0.1, 0.05) -) - -var fog_colors = fixed_array( - float3(0.52, 0.68, 0.88), // blue water: pale blue haze - float3(0.82, 0.72, 0.48), // desert: warm sandy haze - float3(0.48, 0.68, 0.48), // forest: pale green mist - float3(0.16, 0.20, 0.36), // night: dark blue mist - float3(0.68, 0.40, 0.24), // volcanic: reddish haze - float3(0.52, 0.68, 0.88), - float3(0.82, 0.72, 0.48), - float3(0.48, 0.68, 0.48), - float3(0.16, 0.20, 0.36), - float3(0.68, 0.40, 0.24) -) - -var @live fog_color_from = float3(0.52, 0.68, 0.88) -var @live fog_color_to = float3(0.52, 0.68, 0.88) - -// --- Shader Interface --- - -var @in @location = 0 v_position : float3 -var @in @location = 1 v_normal : float3 -var @in @location = 2 v_texture : float2 -var @uniform v_model : float4x4 -var @uniform v_view : float4x4 -var @uniform v_projection : float4x4 -var @inout f_normal : float3 -var @inout f_tex_pos : float3 -var @inout f_fog_t : float -var @inout f_grass_world : float2 -var @uniform f_Color : float4 -var @uniform f_RiverColor : float4 -var @uniform f_GameTime : float -var @uniform f_FogColor : float3 -var @uniform f_FogParams : float2 -var @out f_FragColor : float4 - -// flat shader — bullets, particles, terrain quads - -[vertex_program] -def vs_flat { - let world_pos = v_model * float4(v_position, 1.0) - f_fog_t = world_pos.y - gl_Position = v_projection * v_view * world_pos -} - -[fragment_program] -def fs_flat { - let fog_t = saturate((f_fog_t - f_FogParams.x) / (f_FogParams.y - f_FogParams.x)) - let fog_alpha = fog_t * fog_t * 0.5 - let col = f_Color.xyz * (1.0 - fog_alpha) + f_FogColor * fog_alpha - f_FragColor = float4(col, f_Color.w) -} - -// grass shader — river banks with subtle world-space noise tint - -[vertex_program] -def vs_grass { - let world_pos = v_model * float4(v_position, 1.0) - f_grass_world = world_pos.xy - f_fog_t = world_pos.y - gl_Position = v_projection * v_view * world_pos -} - -[fragment_program] -def fs_grass { - let p = f_grass_world * 0.35 - let h = sin(dot(floor(p), float2(12.9898, 78.233))) * 43758.5 - let n = h - floor(h) - let tint = 1.0 + (n - 0.5) * 0.18 - let base = f_Color.xyz * tint - let fog_t = saturate((f_fog_t - f_FogParams.x) / (f_FogParams.y - f_FogParams.x)) - let fog_alpha = fog_t * fog_t * 0.5 - let col = base * (1.0 - fog_alpha) + f_FogColor * fog_alpha - f_FragColor = float4(col, f_Color.w) -} - -// phong shader — player, enemies, bridges, islands - -let LIGHT_DIR = normalize(float3(0.4, -0.6, -1.0)) - -[vertex_program] -def vs_phong { - let world_pos = v_model * float4(v_position, 1.0) - f_normal = normalize(float3x3(v_model) * v_normal) - f_fog_t = world_pos.y - gl_Position = v_projection * v_view * world_pos -} - -[fragment_program] -def fs_phong { - let n = normalize(f_normal) - let k = saturate(-dot(LIGHT_DIR, n)) - let lit = f_Color.xyz * (k * 0.75 + 0.25) - let fog_t = saturate((f_fog_t - f_FogParams.x) / (f_FogParams.y - f_FogParams.x)) - let fog_alpha = fog_t * fog_t * 0.5 - let col = lit * (1.0 - fog_alpha) + f_FogColor * fog_alpha - f_FragColor = float4(col, f_Color.w) -} - -// river surface shader — animated water - -[vertex_program] -def vs_river { - let world_pos = v_model * float4(v_position, 1.0) - f_tex_pos = world_pos.xyz - f_fog_t = world_pos.y - gl_Position = v_projection * v_view * world_pos -} - -[fragment_program] -def fs_river { - let shimmer = 0.07 * sin(f_GameTime * 2.3 + f_tex_pos.x * 0.8 + f_tex_pos.y * 0.5) - let c = f_RiverColor.xyz + float3(shimmer) - let fog_t = saturate((f_fog_t - f_FogParams.x) / (f_FogParams.y - f_FogParams.x)) - let fog_alpha = fog_t * fog_t * 0.5 - let col = c * (1.0 - fog_alpha) + f_FogColor * fog_alpha - f_FragColor = float4(col, 1.0) -} - -// rotor shader — alpha-blended propeller cross - -[vertex_program] -def vs_rotor { - let world_pos = v_model * float4(v_position, 1.0) - f_tex_pos = float3(v_texture.x, 0.0, v_texture.y) - f_fog_t = world_pos.y - gl_Position = v_projection * v_view * world_pos -} - -[fragment_program] -def fs_rotor { - let u = f_tex_pos.x - let v = f_tex_pos.z - let d1 = abs(u - v) - let d2 = abs(u + v - 1.0) - let min_dist = min(d1, d2) - let t = saturate(1.0 - min_dist * 3.5) - let alpha = f_Color.w * (t * t * t * t) - let fog_t = saturate((f_fog_t - f_FogParams.x) / (f_FogParams.y - f_FogParams.x)) - let fog_alpha = fog_t * fog_t * 0.5 - let col = f_Color.xyz * (1.0 - fog_alpha) + f_FogColor * fog_alpha - f_FragColor = float4(col, alpha) +var env_from : SectionEnv +var env_to : SectionEnv +var env_now : SectionEnv + +def refresh_env() { + env_now <- lerp_env(env_from, env_to, saturate(section_color_blend_t)) } -// --- GL Object Handles --- +def set_env_immediate(idx : int) { + env_from <- section_env(idx) + env_to <- section_env(idx) + section_color_blend_t = 1.0 + refresh_env() +} + +def begin_env_transition(idx : int) { + env_from <- env_now + env_to <- section_env(idx) + section_color_blend_t = 0.0 +} -var flat_program : uint -var phong_program : uint -var river_program : uint -var rotor_program : uint -var grass_program : uint -var active_program : uint +// --- GL Object Handles --- var geo_sphere : OpenGLGeometryFragment var geo_cube : OpenGLGeometryFragment @@ -501,6 +543,11 @@ var geo_plane_xz : OpenGLGeometryFragment var geo_cylinder : OpenGLGeometryFragment var geo_cone : OpenGLGeometryFragment var geo_prism : OpenGLGeometryFragment +var geo_disc : OpenGLGeometryFragment +var geo_heli : OpenGLGeometryFragment +var geo_heli_tail : OpenGLGeometryFragment +var geo_gunboat : OpenGLGeometryFragment +var geo_jet : OpenGLGeometryFragment var hud_font : Font? var river_bank_geo : OpenGLGeometryFragment var river_surface_geo : OpenGLGeometryFragment @@ -521,27 +568,19 @@ def random_sign() : float { } def section_idx() : int { - return min(current_section, 9) + return min(current_section, MAX_SECTIONS - 1) } def get_river_color() : float3 { - // River transitions are intentionally slower than shore transitions. - let t = saturate(section_color_blend_t * 0.42) - let c = river_color_from * (1.0 - t) + river_color_to * t - // Keep a persistent blue bias regardless of section palette. - let blue_base = float3(0.08, 0.36, 0.74) - let blended = c * 0.78 + blue_base * 0.22 - return float3(blended.x * 0.92, blended.y * 0.95, min(blended.z + 0.08, 1.0)) + return env_now.river_color } def get_bank_color() : float3 { - let t = saturate(section_color_blend_t) - return bank_color_from * (1.0 - t) + bank_color_to * t + return env_now.bank_color } def get_fog_color() : float3 { - let t = saturate(section_color_blend_t) - return fog_color_from * (1.0 - t) + fog_color_to * t + return env_now.fog_color } def section_speed_mult() : float { @@ -556,20 +595,14 @@ def fuel_bar_color() : float3 { let low = player_fuel < LOW_FUEL_THRESHOLD let blink = low && int(get_uptime() * 6.0) % 2 == 0 return ( - low ? (blink ? float3(1.0, 0.2, 0.2) : float3(1.0, 0.5, 0.1)) : float3(0.2, 0.9, 0.3) + low ? (blink ? float3(1.0, 0.2, 0.2) : float3(1.0, 0.5, 0.1)) : float3(0.25, 0.95, 0.42) ) } -def draw_with_flat(pos, scale, color : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0)) { - v_model = compose(pos, rot_quat, scale) - f_Color = float4(color, 1.0) - vs_flat_bind_uniform(active_program) - fs_flat_bind_uniform(active_program) -} - -def draw_with_phong(pos, scale, color : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0)) { - v_model = compose(pos, rot_quat, scale) - f_Color = float4(color, 1.0) - vs_phong_bind_uniform(active_program) - fs_phong_bind_uniform(active_program) +// Screen-space scale so the HUD keeps its designed proportions on a HiDPI +// framebuffer. display_w/display_h are framebuffer pixels, not window points -- +// on a retina display that is 2x, which is why the old fixed-pixel HUD came out +// half size. +def hud_scale() : float { + return max(float(display_h) / 720.0, 0.5) } diff --git a/examples/games/river_run/rr_live.das b/examples/games/river_run/rr_live.das new file mode 100644 index 0000000000..25cf484218 --- /dev/null +++ b/examples/games/river_run/rr_live.das @@ -0,0 +1,174 @@ +options gen2 +options persistent_heap + +require rr_globals public +require river +require gameplay + +// Live-command surface: drives the game from the REST API so a headless +// session can start a run, park the camera on a chosen section palette, and +// stage objects for a screenshot without touching the keyboard. + +struct GameStatusResult { + state : string + score : int + lives : int + fuel : float + section : int + section_dist : float + player_x : float + player_y : float + fwd_speed : float + god_mode : bool + boats : int + planes : int + helis : int + bridges : int + depots : int + islands : int + trees : int + houses : int + bonuses : int + particles : int +} + +[live_command(description = "Score, lives, fuel, section and per-kind entity counts")] +def cmd_game_status(_input : JsonValue?) : JsonValue? { + var result = GameStatusResult( + state = "{game_state}", + score = score, + lives = player_lives, + fuel = player_fuel, + section = current_section, + section_dist = section_dist, + player_x = player_pos.x, + player_y = player_pos.y, + fwd_speed = player_fwd_speed, + god_mode = god_mode + ) + query() $(_b : EnemyBoat) { result.boats++; } + query() $(_p : EnemyPlane) { result.planes++; } + query() $(_h : EnemyHelicopter) { result.helis++; } + query() $(_br : Bridge) { result.bridges++; } + query() $(_d : FuelDepot) { result.depots++; } + query() $(_i : Island) { result.islands++; } + query() $(_t : RiverTree) { result.trees++; } + query() $(_ho : RiverHouse) { result.houses++; } + query() $(_bo : BonusPickup) { result.bonuses++; } + query() $(_pa : Particle) { result.particles++; } + return JV(result) +} + +[live_command(description = "Restart the run from section 0 and enter playing state")] +def cmd_reset_game(_input : JsonValue?) : JsonValue? { + reset_game() + commit() + return JV(true) +} + +struct CmdSetStateArgs { + state : GameState = GameState.playing +} + +[live_command(description = "Force a game state. Args: state (menu|playing|paused|game_over_state|win_state)")] +def cmd_set_state(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + game_state = args.state + restart_input_lock = 0.0 + return JV(args) +} + +struct CmdGodModeArgs { + enabled : bool = true +} + +[live_command(description = "Invulnerable + no fuel drain, for unattended capture. Args: enabled")] +def cmd_god_mode(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + god_mode = args.enabled + if (god_mode) { + player_fuel = PLAYER_FUEL_MAX + } + return JV(args) +} + +struct CmdSteerArgs { + x : float = 0.0 + speed : float = 0.0 +} + +[live_command(description = "Teleport the player across the channel and optionally pin forward speed. Args: x, speed")] +def cmd_steer(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + let bounds = river_clamp_x_for(player_pos.y, args.x) + let margin = PLAYER_SIZE * 1.5 + player_pos.x = clamp(args.x, bounds.x + margin, bounds.y - margin) + player_vel.x = 0.0 + if (args.speed > 0.0) { + player_fwd_speed = clamp(args.speed, PLAYER_FWD_SPEED_MIN, + PLAYER_FWD_SPEED_MAX * section_speed_mult()) + } + return JV(args) +} + +struct CmdSetSectionArgs { + section : int = 0 +} + +[live_command(description = "Jump to a section palette and repopulate it. Args: section (0..9)")] +def cmd_set_section(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + current_section = clamp(args.section, 0, MAX_SECTIONS - 1) + section_dist = 0.0 + set_env_immediate(section_idx()) + spawn_section_objects() + commit() + return JV(args) +} + +enum SpawnKind { + boat + plane + heli + depot + bridge + island + tree + house + bonus +} + +struct CmdSpawnArgs { + kind : SpawnKind = SpawnKind.boat + ahead : float = 22.0 + offset : float = 0.0 +} + +[live_command(description = "Stage one object ahead of the player. Args: kind, ahead, offset")] +def cmd_spawn(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + let y = player_pos.y + args.ahead + let bounds = river_clamp_x_for(y, player_pos.x + args.offset) + let x = clamp(player_pos.x + args.offset, bounds.x + 0.6, bounds.y - 0.6) + if (args.kind == SpawnKind.boat) { + spawn_enemy_boat(float3(x, y, BOAT_Z)) + } elif (args.kind == SpawnKind.plane) { + spawn_enemy_plane(float3(x, y, PLANE_Z)) + } elif (args.kind == SpawnKind.heli) { + spawn_enemy_heli(float3(x, y, ENEMY_HELI_Z)) + } elif (args.kind == SpawnKind.depot) { + spawn_fuel_depot(float3(x, y, FUEL_DEPOT_Z)) + } elif (args.kind == SpawnKind.bridge) { + spawn_bridge(float3(0.0, y, BRIDGE_Z)) + } elif (args.kind == SpawnKind.island) { + spawn_island(float3(x, y, ISLAND_Z)) + } elif (args.kind == SpawnKind.tree) { + spawn_river_tree(float3(bounds.y + 3.0, y, 0.0), 2.0, 3, 0.5, 0.0, 0.35) + } elif (args.kind == SpawnKind.house) { + spawn_river_house(float3(bounds.y + 5.0, y, 0.0), 1.6, 0.3, 0.4) + } else { + spawn_bonus_pickup(float3(x, y, 0.0), BonusType.multishot) + } + commit() + return JV(args) +} diff --git a/examples/games/river_run/rr_models.das b/examples/games/river_run/rr_models.das new file mode 100644 index 0000000000..eb66947a3d --- /dev/null +++ b/examples/games/river_run/rr_models.das @@ -0,0 +1,261 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require rr_shaders public + +// Composite meshes for the things the player actually looks at. +// +// Each model is welded from transformed primitives into ONE GeometryFragment, +// with the part index baked into uv.x and a gloss modifier into uv.y. The prop +// shader resolves uv.x against four tint uniforms, so a whole painted hull is a +// single draw call and an enemy is the same mesh under a different palette. +// +// Local frames are nose-forward (+y), up (+z), and roughly unit-scaled, so a +// call site scales by one radius and gets sane proportions. + +// Part slots, shared by every model: 0 body, 1 canopy/accent, 2 metal, 3 dark. +let PART_BODY = 0.0 +let PART_ACCENT = 1.0 +let PART_METAL = 2.0 +let PART_DARK = 3.0 + +let GLOSS_MATTE = 0.1 +let GLOSS_SEMI = 0.45 +let GLOSS_SHINY = 1.0 + +// Weld `src` into `dst` under (pos, rot, scale). Normals go through +// rotation * (n / scale), which is the inverse-transpose for a rot-scale pair -- +// without the division a stretched fuselage would light as if it were round. +def private weld(var dst : GeometryFragment; src : GeometryFragment; + pos, scale : float3; rot : float4; part, gloss : float) { + let base = length(dst.vertices) + let m = compose(pos, rot, scale) + let inv_scale = float3(1.0 / scale.x, 1.0 / scale.y, 1.0 / scale.z) + dst.vertices |> reserve(base + length(src.vertices)) + for (v in src.vertices) { + let p = m * float4(v.xyz, 1.0) + let n = m * float4(v.normal * inv_scale, 0.0) + dst.vertices |> push(GeometryPreviewVertex( + xyz = p.xyz, + normal = normalize(n.xyz), + uv = float2(part, gloss) + )) + } + dst.indices |> reserve(length(dst.indices) + length(src.indices)) + for (i in src.indices) { + dst.indices |> push(base + i) + } +} + +def private no_rot() : float4 { + return float4(0.0, 0.0, 0.0, 1.0) +} + +def private axis_rot(axis : float3; angle : float) : float4 { + let h = angle * 0.5 + let s = sin(h) + return float4(axis.x * s, axis.y * s, axis.z * s, cos(h)) +} + +// A flat disc in the XY plane with uv in [0,1]^2, used for the rotor sweep. +def gen_disc(segments : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + frag.vertices |> reserve(segments + 2) + frag.indices |> reserve(segments * 3) + // Fan centre first, then the rim. + frag.vertices |> push(GeometryPreviewVertex( + xyz = float3(0.0), + normal = float3(0.0, 0.0, 1.0), + uv = float2(0.5, 0.5) + )) + for (i in range(segments + 1)) { + let a = float(i) * (2.0 * PI / float(segments)) + let c = float2(cos(a), sin(a)) + frag.vertices |> push(GeometryPreviewVertex( + xyz = float3(c.x, c.y, 0.0), + normal = float3(0.0, 0.0, 1.0), + uv = c * 0.5 + float2(0.5) + )) + } + for (i in range(segments)) { + // One fan triangle per rim step: centre, this rim vertex, the next. + frag.indices |> push_from([0, i + 1, i + 2]) + } + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Helicopter +// ============================================================================ + +def gen_helicopter() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + var body <- gen_sphere(14, 10, false) + var tube <- gen_cylinder(GenDirection.xy, 12) + var box <- gen_cube() + + let y_axis = float3(0.0, 1.0, 0.0) + let x_axis = float3(1.0, 0.0, 0.0) + + // Fuselage: a stretched ellipsoid, widest at the cabin and tapering aft. + weld(frag, body, float3(0.0, 0.02, 0.30), float3(0.32, 0.58, 0.30), no_rot(), PART_BODY, GLOSS_SEMI) + // Chin / nose, dropped and pushed forward so the silhouette has a beak. + weld(frag, body, float3(0.0, 0.46, 0.24), float3(0.24, 0.26, 0.20), no_rot(), PART_BODY, GLOSS_SEMI) + // Canopy glass, sitting proud of the fuselage. + weld(frag, body, float3(0.0, 0.34, 0.40), float3(0.24, 0.30, 0.20), no_rot(), PART_ACCENT, GLOSS_SHINY) + + // Tail boom running aft, then the fin and stabiliser that read as a tail + // even at the distance the camera keeps. + weld(frag, tube, float3(0.0, -0.62, 0.32), float3(0.075, 0.075, 0.62), + axis_rot(x_axis, PI * 0.5), PART_BODY, GLOSS_SEMI) + weld(frag, box, float3(0.0, -1.12, 0.46), float3(0.035, 0.16, 0.22), no_rot(), PART_BODY, GLOSS_SEMI) + weld(frag, box, float3(0.0, -1.02, 0.34), float3(0.30, 0.09, 0.028), no_rot(), PART_BODY, GLOSS_SEMI) + // Tail-rotor hub on the left face of the fin. + weld(frag, tube, float3(-0.07, -1.14, 0.46), float3(0.05, 0.05, 0.05), + axis_rot(y_axis, PI * 0.5), PART_METAL, GLOSS_SHINY) + + // Engine deck and rotor mast. + weld(frag, box, float3(0.0, -0.10, 0.56), float3(0.15, 0.26, 0.09), no_rot(), PART_DARK, GLOSS_MATTE) + weld(frag, tube, float3(0.0, -0.02, 0.66), float3(0.05, 0.05, 0.10), no_rot(), PART_METAL, GLOSS_SHINY) + // Exhaust stub. + weld(frag, tube, float3(0.13, -0.30, 0.50), float3(0.045, 0.045, 0.11), + axis_rot(x_axis, PI * 0.5), PART_DARK, GLOSS_MATTE) + + // Landing skids: two rails on four struts. They give the hull a ground + // plane to sit against and read strongly in the shadow silhouette. + for (side in fixed_array(-1.0, 1.0)) { + weld(frag, tube, float3(side * 0.26, 0.02, 0.045), float3(0.028, 0.028, 0.44), + axis_rot(x_axis, PI * 0.5), PART_METAL, GLOSS_SEMI) + for (fore in fixed_array(-0.24, 0.22)) { + weld(frag, tube, float3(side * 0.20, fore, 0.15), float3(0.022, 0.022, 0.12), + axis_rot(y_axis, side * 0.42), PART_METAL, GLOSS_SEMI) + } + } + + delete body + delete tube + delete box + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Gunboat +// ============================================================================ + +def gen_gunboat() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + var box <- gen_cube() + var tube <- gen_cylinder(GenDirection.xy, 10) + var wedge <- gen_prism(GenDirection.xy) + var body <- gen_sphere(12, 8, false) + + let x_axis = float3(1.0, 0.0, 0.0) + let z_axis = float3(0.0, 0.0, 1.0) + + // Hull: a slab with a wedge bow, so the boat has a direction on the water. + weld(frag, box, float3(0.0, -0.10, 0.15), float3(0.30, 0.72, 0.15), no_rot(), PART_BODY, GLOSS_SEMI) + weld(frag, wedge, float3(0.0, 0.78, 0.15), float3(0.30, 0.34, 0.15), + axis_rot(z_axis, -PI * 0.5), PART_BODY, GLOSS_SEMI) + // Gunwale strip: a lighter band along the top edge that catches the sun. + weld(frag, box, float3(0.0, -0.10, 0.30), float3(0.31, 0.73, 0.025), no_rot(), PART_ACCENT, GLOSS_SEMI) + + // Deck house with a windscreen. + weld(frag, box, float3(0.0, -0.44, 0.46), float3(0.21, 0.26, 0.17), no_rot(), PART_ACCENT, GLOSS_SEMI) + weld(frag, box, float3(0.0, -0.19, 0.50), float3(0.17, 0.02, 0.10), no_rot(), PART_DARK, GLOSS_SHINY) + + // Forward turret and barrel -- the part that tells the player it shoots. + weld(frag, body, float3(0.0, 0.28, 0.38), float3(0.19, 0.19, 0.14), no_rot(), PART_METAL, GLOSS_SEMI) + weld(frag, tube, float3(0.0, 0.62, 0.42), float3(0.045, 0.045, 0.30), + axis_rot(x_axis, PI * 0.5), PART_METAL, GLOSS_SHINY) + + // Mast. + weld(frag, tube, float3(0.0, -0.60, 0.76), float3(0.018, 0.018, 0.16), no_rot(), PART_METAL, GLOSS_SEMI) + + delete box + delete tube + delete wedge + delete body + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Jet +// ============================================================================ + +def gen_jet() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + var body <- gen_sphere(12, 9, false) + var tube <- gen_cylinder(GenDirection.xy, 10) + var cone <- gen_cone(GenDirection.xy, 10) + var wedge <- gen_prism(GenDirection.xy) + var box <- gen_cube() + + let x_axis = float3(1.0, 0.0, 0.0) + let z_axis = float3(0.0, 0.0, 1.0) + + // Fuselage plus a sharp nose cone. + weld(frag, body, float3(0.0, 0.0, 0.0), float3(0.15, 0.62, 0.15), no_rot(), PART_BODY, GLOSS_SEMI) + weld(frag, cone, float3(0.0, 0.80, 0.0), float3(0.13, 0.13, 0.34), + axis_rot(x_axis, -PI * 0.5), PART_BODY, GLOSS_SEMI) + + // Delta wings, swept back and slightly anhedral. + for (side in fixed_array(-1.0, 1.0)) { + weld(frag, wedge, float3(side * 0.46, -0.12, -0.02), float3(0.44, 0.34, 0.035), + axis_rot(z_axis, side * PI * 0.5), PART_BODY, GLOSS_SEMI) + // Wingtip pods read as ordnance and stop the wing from ending in a line. + weld(frag, tube, float3(side * 0.80, -0.06, -0.02), float3(0.035, 0.035, 0.16), + axis_rot(x_axis, PI * 0.5), PART_METAL, GLOSS_SEMI) + // Intakes. + weld(frag, box, float3(side * 0.20, 0.10, -0.05), float3(0.055, 0.20, 0.075), + no_rot(), PART_DARK, GLOSS_MATTE) + } + + // Canopy and twin tail fins. + weld(frag, body, float3(0.0, 0.20, 0.13), float3(0.10, 0.24, 0.09), no_rot(), PART_ACCENT, GLOSS_SHINY) + for (side in fixed_array(-1.0, 1.0)) { + weld(frag, box, float3(side * 0.13, -0.62, 0.16), float3(0.022, 0.16, 0.16), + axis_rot(float3(0.0, 1.0, 0.0), side * 0.22), PART_BODY, GLOSS_SEMI) + } + + // Exhaust nozzle; the call site drives its glow through the emissive slot. + weld(frag, tube, float3(0.0, -0.70, 0.0), float3(0.11, 0.11, 0.09), + axis_rot(x_axis, PI * 0.5), PART_DARK, GLOSS_MATTE) + + delete body + delete tube + delete cone + delete wedge + delete box + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Palette binding +// ============================================================================ + +// Bind a four-slot palette for the next model draw. `tint` multiplies every +// slot, which is how the damage flash and the invulnerability pulse are applied +// without a second material. +def set_model_palette(body, accent, metal, dark : float3) { + f_PaletteOn = 1.0 + f_Tint0 = body + f_Tint1 = accent + f_Tint2 = metal + f_Tint3 = dark +} + +def clear_model_palette() { + f_PaletteOn = 0.0 +} diff --git a/examples/games/river_run/rr_postfx.das b/examples/games/river_run/rr_postfx.das new file mode 100644 index 0000000000..099bcf1804 --- /dev/null +++ b/examples/games/river_run/rr_postfx.das @@ -0,0 +1,743 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require opengl/opengl_boost public +require opengl/opengl_cache public +require live_host public +require live/live_vars public +require daslib/math_boost public +require daslib/safe_addr public + +// Offscreen render targets and the screen-space chain that turns the raw scene +// pass into the finished frame: ambient occlusion, bloom, sun shafts, filmic +// tone mapping, vignette, grain and FXAA. +// +// Everything is held to GLSL ES 3.00 / WebGL2. The one capability that is not +// guaranteed there is a float-renderable colour attachment, so the HDR targets +// are probed at creation with glCheckFramebufferStatus and silently fall back to +// RGBA8 (bloom then works off a pre-exposed LDR buffer -- dimmer, never broken). + +let SHADOW_SIZE = 2048 +let BLOOM_LEVELS = 4 + +// Render-target handles live across a live reload, so init() reuses them +// instead of orphaning a set of GL objects the reloaded script can no longer +// name. +var @live fx_width = 0 +var @live fx_height = 0 +var @live fx_hdr = false + +var @live scene_fbo = 0u +var @live scene_color = 0u +var @live scene_nd = 0u +var @live scene_depth_rb = 0u + +var @live shadow_fbo = 0u +var @live shadow_tex = 0u + +var @live ldr_fbo = 0u +var @live ldr_tex = 0u + +var @live ao_fbo = 0u +var @live ao_tex = 0u +var @live ao_blur_fbo = 0u +var @live ao_blur_tex = 0u + +var @live shaft_fbo = 0u +var @live shaft_tex = 0u +var @live occl_fbo = 0u +var @live occl_tex = 0u + +var @live bloom_fbo : array +var @live bloom_tex : array +var @live bloom_w : array +var @live bloom_h : array + +var @live empty_vao = 0u + +// ============================================================================ +// Post-process shader interface +// ============================================================================ + +var @inout p_uv : float2 + +var @uniform @stage = 0 p_tex0 : sampler2D +var @uniform @stage = 1 p_tex1 : sampler2D +var @uniform @stage = 2 p_tex2 : sampler2D +var @uniform p_texel : float2 +var @uniform p_params : float4 +var @uniform p_params2 : float4 +var @uniform p_tint : float3 + +var @out p_FragColor : float4 + +// One attributeless triangle covering the viewport; cheaper than a quad and +// avoids the diagonal seam. +[vertex_program] +def vs_post { + let id = gl_VertexIndex + p_uv = float2(float((id << 1) & 2), float(id & 2)) + gl_Position = float4(p_uv * 2.0 - float2(1.0), 0.0, 1.0) +} + +// --- Bright pass: soft-knee threshold, so highlights ramp in instead of popping + +[fragment_program] +def fs_bright { + let c = texture(p_tex0, p_uv).xyz + let luma = dot(c, float3(0.2126, 0.7152, 0.0722)) + let threshold = p_params.x + let knee = p_params.y + let soft = clamp(luma - threshold + knee, 0.0, 2.0 * knee) + let contrib = max(soft * soft / (4.0 * knee + 0.0001), luma - threshold) / max(luma, 0.0001) + p_FragColor = float4(c * contrib, 1.0) +} + +// --- Progressive downsample (13-tap box, the standard dual-filter kernel) + +[fragment_program] +def fs_down { + let t = p_texel + var sum = texture(p_tex0, p_uv).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(-t.x, -t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(0.0, -t.y)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(-t.x, 0.0)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(t.x, 0.0)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(0.0, t.y)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(t.x, t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(-t.x, -t.y) * 0.5).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(t.x, -t.y) * 0.5).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(-t.x, t.y) * 0.5).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(t.x, t.y) * 0.5).xyz * 0.0625 + p_FragColor = float4(sum, 1.0) +} + +// --- Tent upsample, additively blended onto the next larger level + +[fragment_program] +def fs_up { + let t = p_texel * p_params.x + var sum = texture(p_tex0, p_uv).xyz * 4.0 + sum += texture(p_tex0, p_uv + float2(-t.x, 0.0)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(t.x, 0.0)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(0.0, -t.y)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(0.0, t.y)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(-t.x, -t.y)).xyz + sum += texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz + sum += texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz + sum += texture(p_tex0, p_uv + float2(t.x, t.y)).xyz + p_FragColor = float4(sum * (1.0 / 16.0), 1.0) +} + +// --- Ambient occlusion +// +// Hemisphere sampling in view space, reconstructed from the linear depth packed +// in the scene normal target's alpha. Half resolution; the blur below hides the +// sample noise. + +def private view_from_uv(uv : float2; depth : float) : float3 { + let ndc = uv * 2.0 - float2(1.0) + return float3(ndc.x * p_params2.x * depth, ndc.y * p_params2.y * depth, -depth) +} + +[fragment_program] +def fs_ssao { + let nd = texture(p_tex0, p_uv) + let depth = nd.w * p_params2.z + if (nd.w >= 0.999) { + p_FragColor = float4(1.0) + return + } + let origin = view_from_uv(p_uv, depth) + let nrm = normalize(nd.xyz * 2.0 - float3(1.0)) + let radius = p_params.x + let rot = hash_noise(p_uv * p_params.z) * 6.2831853 + + var occlusion = 0.0 + let taps = 12 + for (i in range(taps)) { + let fi = float(i) + // Golden-angle spiral, rotated per pixel, with the radius growing as + // sqrt so samples stay area-uniform across the hemisphere. + let ang = rot + fi * 2.399963 + let rad = radius * sqrt((fi + 0.5) / float(taps)) + let dir = float3(cos(ang) * rad, sin(ang) * rad, 0.0) + var sample_pos = origin + dir + sample_pos += nrm * rad * 0.65 + + let clip = p_params2.w + let su = float2(sample_pos.x / (p_params2.x * -sample_pos.z), sample_pos.y / (p_params2.y * -sample_pos.z)) + let suv = su * 0.5 + float2(0.5) + // Guarded rather than `continue`d on purpose: the GLSL emitter lowers a + // range for-loop to a while() whose increment is the last statement of + // the body, so a `continue` skips the increment and spins forever. + let inside = (suv.x >= 0.0 && suv.x <= 1.0 && suv.y >= 0.0 && suv.y <= 1.0) + if (inside) { + let sample_depth = texture(p_tex0, suv).w * p_params2.z + let delta = -sample_pos.z - sample_depth + let range_check = saturate(radius / max(abs(-origin.z - sample_depth), 0.0001)) + if (delta > 0.02 * clip) { + occlusion += range_check + } + } + } + let ao = saturate(1.0 - occlusion / float(taps) * p_params.y) + p_FragColor = float4(ao, ao, ao, 1.0) +} + +def private hash_noise(p : float2) : float { + return (p.x * 0.0 + fract_local(sin(dot(floor(p), float2(12.9898, 78.233))) * 43758.545)) +} + +def private fract_local(x : float) : float { + return x - floor(x) +} + +// --- Cross-shaped AO blur (cheap, and the AO signal is already low frequency) + +[fragment_program] +def fs_ao_blur { + var sum = 0.0 + for (y in range(-2, 3)) { + for (x in range(-2, 3)) { + sum += texture(p_tex0, p_uv + float2(float(x), float(y)) * p_texel).x + } + } + p_FragColor = float4(float3(sum * (1.0 / 25.0)), 1.0) +} + +// --- Sun shafts +// +// Extract the parts of the frame that are sky (linear depth at the far plane) +// and near the sun on screen, then radially blur that buffer away from the sun +// position. Classic screen-space light scattering; no extra geometry. + +[fragment_program] +def fs_occlusion { + let nd = texture(p_tex1, p_uv) + let sky = step(0.995, nd.w) + let c = texture(p_tex0, p_uv).xyz + let d = distance(p_uv, p_params.xy) + let falloff = saturate(1.0 - d * p_params.z) + p_FragColor = float4(c * sky * falloff * falloff, 1.0) +} + +[fragment_program] +def fs_shafts { + let sun_uv = p_params.xy + let density = p_params.z + var uv = p_uv + let delta = (uv - sun_uv) * (density / 24.0) + var illum = 1.0 + var sum = float3(0.0) + for (_i in range(24)) { + uv -= delta + sum += texture(p_tex0, uv).xyz * illum + illum *= p_params.w + } + p_FragColor = float4(sum * (1.0 / 24.0), 1.0) +} + +// --- Composite: AO, bloom, shafts, exposure, ACES tone map, grade, vignette + +def private aces_tonemap(x : float3) : float3 { + let a = 2.51 + let b = 0.03 + let c = 2.43 + let d = 0.59 + let e = 0.14 + return saturate((x * (a * x + float3(b))) / (x * (c * x + float3(d)) + float3(e))) +} + +[fragment_program] +def fs_composite { + var color = texture(p_tex0, p_uv).xyz + let ao = texture(p_tex1, p_uv).x + let bloom = texture(p_tex2, p_uv).xyz + + // AO only darkens ambient-lit areas; applying it to the whole signal eats + // the sun highlights and reads as dirt. + color *= lerp(1.0, ao, p_params.z) + color += bloom * p_params.y + color *= p_params.x + + var mapped = aces_tonemap(color) + + // Grade: lift the shadows toward the section's fog colour, push contrast. + mapped = lerp(mapped, mapped * mapped * (float3(3.0) - 2.0 * mapped), p_params.w) + mapped = lerp(mapped, p_tint * dot(mapped, float3(0.2126, 0.7152, 0.0722)), p_params2.x) + + p_FragColor = float4(mapped, 1.0) +} + +// --- FXAA + vignette + grain, straight to the back buffer + +def private luma(c : float3) : float { + return dot(c, float3(0.299, 0.587, 0.114)) +} + +[fragment_program] +def fs_present { + let t = p_texel + let rgb_m = texture(p_tex0, p_uv).xyz + let l_nw = luma(texture(p_tex0, p_uv + float2(-t.x, -t.y)).xyz) + let l_ne = luma(texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz) + let l_sw = luma(texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz) + let l_se = luma(texture(p_tex0, p_uv + float2(t.x, t.y)).xyz) + let l_m = luma(rgb_m) + + let l_min = min(l_m, min(min(l_nw, l_ne), min(l_sw, l_se))) + let l_max = max(l_m, max(max(l_nw, l_ne), max(l_sw, l_se))) + + var dir = float2(-((l_nw + l_ne) - (l_sw + l_se)), ((l_nw + l_sw) - (l_ne + l_se))) + let reduce = max((l_nw + l_ne + l_sw + l_se) * 0.03125, 0.0078125) + let rcp = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce) + dir = clamp(dir * rcp, float2(-8.0), float2(8.0)) * t + + let rgb_a = 0.5 * (texture(p_tex0, p_uv + dir * (1.0 / 3.0 - 0.5)).xyz + + texture(p_tex0, p_uv + dir * (2.0 / 3.0 - 0.5)).xyz) + let rgb_b = rgb_a * 0.5 + 0.25 * (texture(p_tex0, p_uv + dir * -0.5).xyz + + texture(p_tex0, p_uv + dir * 0.5).xyz) + let l_b = luma(rgb_b) + var color = ((l_b < l_min) || (l_b > l_max)) ? rgb_a : rgb_b + + // Vignette, then a touch of chromatic falloff at the very edge. + let center = p_uv - float2(0.5) + let r2 = dot(center, center) + color *= 1.0 - saturate(r2 * p_params.x) * p_params.y + + // Animated grain, scaled down in the bright parts so it stays filmic. + let grain = fract_local(sin(dot(p_uv * p_params2.y + float2(p_params.w), float2(12.9898, 78.233))) * 43758.545) - 0.5 + color += float3(grain * p_params.z * (1.0 - luma(color) * 0.6)) + + p_FragColor = float4(color, 1.0) +} + +// ============================================================================ +// Program handles +// ============================================================================ + +var bright_program : uint +var down_program : uint +var up_program : uint +var ssao_program : uint +var ao_blur_program : uint +var occlusion_program : uint +var shafts_program : uint +var composite_program : uint +var present_program : uint + +def create_postfx_programs() { + bright_program = cache_shader_program(vs_post`shader_text, fs_bright`shader_text) + down_program = cache_shader_program(vs_post`shader_text, fs_down`shader_text) + up_program = cache_shader_program(vs_post`shader_text, fs_up`shader_text) + ssao_program = cache_shader_program(vs_post`shader_text, fs_ssao`shader_text) + ao_blur_program = cache_shader_program(vs_post`shader_text, fs_ao_blur`shader_text) + occlusion_program = cache_shader_program(vs_post`shader_text, fs_occlusion`shader_text) + shafts_program = cache_shader_program(vs_post`shader_text, fs_shafts`shader_text) + composite_program = cache_shader_program(vs_post`shader_text, fs_composite`shader_text) + present_program = cache_shader_program(vs_post`shader_text, fs_present`shader_text) +} + +// ============================================================================ +// Render targets +// ============================================================================ + +def private make_texture(w, h : int; internal_format : uint; format, data_type, filter : uint) : uint { + var tex = 0u + glGenTextures(1, safe_addr(tex)) + glBindTexture(GL_TEXTURE_2D, tex) + glTexImage2D(GL_TEXTURE_2D, 0, int(internal_format), w, h, 0, format, data_type, null) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter) + // GLES 3.0 has no CLAMP_TO_BORDER, so every target clamps to edge and the + // shaders range-check explicitly where a border would have mattered. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + return tex +} + +def private drop_texture(var tex : uint) { + if (tex != 0u) { + glDeleteTextures(1, unsafe(addr(tex))) + tex = 0u + } +} + +def private drop_fbo(var fbo : uint) { + if (fbo != 0u) { + glDeleteFramebuffers(1, unsafe(addr(fbo))) + fbo = 0u + } +} + +def private make_color_fbo(tex : uint) : uint { + var fbo = 0u + glGenFramebuffers(1, safe_addr(fbo)) + glBindFramebuffer(GL_FRAMEBUFFER, fbo) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0) + return fbo +} + +// Build the two-attachment scene target at the requested colour format and +// report whether the driver will actually render to it. RGBA16F is not a +// guaranteed colour-renderable format on WebGL2 (it needs EXT_color_buffer_float), +// so the caller retries at RGBA8 rather than assuming. +def private try_build_scene(w, h : int; color_format, color_type : uint) : bool { + scene_color = make_texture(w, h, color_format, GL_RGBA, color_type, GL_LINEAR) + scene_nd = make_texture(w, h, color_format, GL_RGBA, color_type, GL_NEAREST) + glGenRenderbuffers(1, safe_addr(scene_depth_rb)) + glBindRenderbuffer(GL_RENDERBUFFER, scene_depth_rb) + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, w, h) + + glGenFramebuffers(1, safe_addr(scene_fbo)) + glBindFramebuffer(GL_FRAMEBUFFER, scene_fbo) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, scene_color, 0) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, scene_nd, 0) + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, scene_depth_rb) + var targets = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1) + glDrawBuffers(2, unsafe(addr(targets[0]))) + let ok = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE + glBindFramebuffer(GL_FRAMEBUFFER, 0u) + if (!ok) { + drop_fbo(scene_fbo) + drop_texture(scene_color) + drop_texture(scene_nd) + glDeleteRenderbuffers(1, safe_addr(scene_depth_rb)) + scene_depth_rb = 0u + } + return ok +} + +def destroy_render_targets() { + drop_fbo(scene_fbo) + drop_texture(scene_color) + drop_texture(scene_nd) + if (scene_depth_rb != 0u) { + glDeleteRenderbuffers(1, safe_addr(scene_depth_rb)) + scene_depth_rb = 0u + } + drop_fbo(ldr_fbo) + drop_texture(ldr_tex) + drop_fbo(ao_fbo) + drop_texture(ao_tex) + drop_fbo(ao_blur_fbo) + drop_texture(ao_blur_tex) + drop_fbo(shaft_fbo) + drop_texture(shaft_tex) + drop_fbo(occl_fbo) + drop_texture(occl_tex) + for (fbo, tex in bloom_fbo, bloom_tex) { + drop_fbo(fbo) + drop_texture(tex) + } + bloom_fbo |> clear() + bloom_tex |> clear() + bloom_w |> clear() + bloom_h |> clear() + fx_width = 0 + fx_height = 0 +} + +def private ensure_shadow_target() { + if (shadow_fbo != 0u) { + return + } + shadow_tex = make_texture(SHADOW_SIZE, SHADOW_SIZE, GL_DEPTH_COMPONENT24, + GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, GL_LINEAR) + // Hardware PCF: the sampler returns the comparison result, so a single + // textureCompare tap is already 2x2 filtered. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL) + + glGenFramebuffers(1, safe_addr(shadow_fbo)) + glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadow_tex, 0) + var none_target = fixed_array(0u) + glDrawBuffers(1, unsafe(addr(none_target[0]))) + glReadBuffer(uint(GL_NONE)) + glBindFramebuffer(GL_FRAMEBUFFER, 0u) +} + +def ensure_render_targets(w, h : int) { + if (w <= 0 || h <= 0) { + return + } + if (empty_vao == 0u) { + glGenVertexArrays(1, safe_addr(empty_vao)) + } + ensure_shadow_target() + if (w == fx_width && h == fx_height && scene_fbo != 0u) { + return + } + destroy_render_targets() + fx_width = w + fx_height = h + + fx_hdr = try_build_scene(w, h, GL_RGBA16F, GL_HALF_FLOAT) + if (!fx_hdr) { + to_log(LOG_WARNING, "river_run: no float colour attachment, post chain runs at 8 bits\n") + if (!try_build_scene(w, h, GL_RGBA8, GL_UNSIGNED_BYTE)) { + to_log(LOG_ERROR, "river_run: scene framebuffer is incomplete\n") + return + } + } + let color_format = (fx_hdr ? GL_RGBA16F : GL_RGBA8) + let color_type = (fx_hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE) + + ldr_tex = make_texture(w, h, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + ldr_fbo = make_color_fbo(ldr_tex) + + let aw = max(w / 2, 1) + let ah = max(h / 2, 1) + ao_tex = make_texture(aw, ah, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + ao_fbo = make_color_fbo(ao_tex) + ao_blur_tex = make_texture(aw, ah, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + ao_blur_fbo = make_color_fbo(ao_blur_tex) + + let sw = max(w / 4, 1) + let sh = max(h / 4, 1) + occl_tex = make_texture(sw, sh, color_format, GL_RGBA, color_type, GL_LINEAR) + occl_fbo = make_color_fbo(occl_tex) + shaft_tex = make_texture(sw, sh, color_format, GL_RGBA, color_type, GL_LINEAR) + shaft_fbo = make_color_fbo(shaft_tex) + + var lw = w + var lh = h + bloom_tex |> reserve(BLOOM_LEVELS) + bloom_fbo |> reserve(BLOOM_LEVELS) + bloom_w |> reserve(BLOOM_LEVELS) + bloom_h |> reserve(BLOOM_LEVELS) + for (_i in range(BLOOM_LEVELS)) { + lw = max(lw / 2, 1) + lh = max(lh / 2, 1) + let tex = make_texture(lw, lh, color_format, GL_RGBA, color_type, GL_LINEAR) + bloom_tex |> push(tex) + bloom_fbo |> push(make_color_fbo(tex)) + bloom_w |> push(lw) + bloom_h |> push(lh) + } + glBindFramebuffer(GL_FRAMEBUFFER, 0u) +} + +// ============================================================================ +// Pass driver +// ============================================================================ + +def fullscreen_pass() { + glBindVertexArray(empty_vao) + glDrawArrays(GL_TRIANGLES, 0, 3) +} + +def private bind_target(fbo : uint; w, h : int) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo) + glViewport(0, 0, w, h) +} + +def begin_shadow_pass() { + bind_target(shadow_fbo, SHADOW_SIZE, SHADOW_SIZE) + glClear(GL_DEPTH_BUFFER_BIT) + glEnable(GL_DEPTH_TEST) + glDepthMask(true) + glDisable(GL_BLEND) + // Front-face culling in the shadow pass pushes the depth samples to the far + // side of each hull, which removes acne on the lit side for free. + glEnable(GL_CULL_FACE) + glCullFace(GL_FRONT) +} + +def end_shadow_pass() { + glCullFace(GL_BACK) +} + +def begin_scene_pass(clear_color : float3) { + bind_target(scene_fbo, fx_width, fx_height) + var targets = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1) + glDrawBuffers(2, unsafe(addr(targets[0]))) + glClearColor(clear_color.x, clear_color.y, clear_color.z, 1.0) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glEnable(GL_DEPTH_TEST) + glDepthMask(true) + glDisable(GL_BLEND) +} + +// Alpha-blended geometry must not reach the normal/depth target -- blending +// into it would corrupt the AO and shaft reconstruction -- so the transparent +// span narrows the draw-buffer set to colour only. +def begin_transparent_span() { + var targets = fixed_array(GL_COLOR_ATTACHMENT0) + glDrawBuffers(1, unsafe(addr(targets[0]))) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glDepthMask(false) +} + +def end_transparent_span() { + glDepthMask(true) + glDisable(GL_BLEND) + var targets = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1) + glDrawBuffers(2, unsafe(addr(targets[0]))) +} + +struct PostSettings { + exposure : float = 1.0 + bloom_threshold : float = 1.55 + bloom_knee : float = 0.35 + bloom_strength : float = 0.42 + ao_radius : float = 0.85 + ao_strength : float = 1.15 + ao_apply : float = 0.85 + shaft_uv : float2 = float2(0.5, 0.5) + shaft_on_screen : float = 0.0 + shaft_density : float = 0.85 + shaft_decay : float = 0.965 + shaft_strength : float = 0.65 + contrast : float = 0.22 + grade_tint : float3 = float3(1.0) + grade_amount : float = 0.0 + vignette : float = 1.35 + vignette_strength : float = 0.55 + grain : float = 0.035 + tan_half_fov : float2 = float2(0.5, 0.5) + z_far : float = 500.0 + time : float = 0.0 +} + +def private run_ssao(cfg : PostSettings) { + glUseProgram(ssao_program) + bind_target(ao_fbo, max(fx_width / 2, 1), max(fx_height / 2, 1)) + p_tex0 := scene_nd + p_params = float4(cfg.ao_radius, cfg.ao_strength, float(fx_width) * 0.37, 0.0) + p_params2 = float4(cfg.tan_half_fov.x, cfg.tan_half_fov.y, cfg.z_far, 1.0) + vs_post_bind_uniform(ssao_program) + fs_ssao_bind_uniform(ssao_program) + fullscreen_pass() + + glUseProgram(ao_blur_program) + bind_target(ao_blur_fbo, max(fx_width / 2, 1), max(fx_height / 2, 1)) + p_tex0 := ao_tex + p_texel = float2(2.0 / float(fx_width), 2.0 / float(fx_height)) + vs_post_bind_uniform(ao_blur_program) + fs_ao_blur_bind_uniform(ao_blur_program) + fullscreen_pass() +} + +def private run_bloom(cfg : PostSettings) { + glUseProgram(bright_program) + bind_target(bloom_fbo[0], bloom_w[0], bloom_h[0]) + p_tex0 := scene_color + p_params = float4(cfg.bloom_threshold, cfg.bloom_knee, 0.0, 0.0) + vs_post_bind_uniform(bright_program) + fs_bright_bind_uniform(bright_program) + fullscreen_pass() + + glUseProgram(down_program) + for (i in range(1, BLOOM_LEVELS)) { + bind_target(bloom_fbo[i], bloom_w[i], bloom_h[i]) + p_tex0 := bloom_tex[i - 1] + p_texel = float2(1.0 / float(bloom_w[i - 1]), 1.0 / float(bloom_h[i - 1])) + vs_post_bind_uniform(down_program) + fs_down_bind_uniform(down_program) + fullscreen_pass() + } + + // Additive tent upsample back down the chain; each level widens the halo. + glUseProgram(up_program) + glEnable(GL_BLEND) + glBlendFunc(uint(GL_ONE), uint(GL_ONE)) + for (step in range(BLOOM_LEVELS - 1)) { + let i = BLOOM_LEVELS - 1 - step + bind_target(bloom_fbo[i - 1], bloom_w[i - 1], bloom_h[i - 1]) + p_tex0 := bloom_tex[i] + p_texel = float2(1.0 / float(bloom_w[i]), 1.0 / float(bloom_h[i])) + p_params = float4(1.35, 0.0, 0.0, 0.0) + vs_post_bind_uniform(up_program) + fs_up_bind_uniform(up_program) + fullscreen_pass() + } + glDisable(GL_BLEND) +} + +def private run_shafts(cfg : PostSettings) { + let sw = max(fx_width / 4, 1) + let sh = max(fx_height / 4, 1) + + glUseProgram(occlusion_program) + bind_target(occl_fbo, sw, sh) + p_tex0 := scene_color + p_tex1 := scene_nd + p_params = float4(cfg.shaft_uv.x, cfg.shaft_uv.y, 0.9, 0.0) + vs_post_bind_uniform(occlusion_program) + fs_occlusion_bind_uniform(occlusion_program) + fullscreen_pass() + + glUseProgram(shafts_program) + bind_target(shaft_fbo, sw, sh) + p_tex0 := occl_tex + p_params = float4(cfg.shaft_uv.x, cfg.shaft_uv.y, cfg.shaft_density, cfg.shaft_decay) + vs_post_bind_uniform(shafts_program) + fs_shafts_bind_uniform(shafts_program) + fullscreen_pass() +} + +// Fold the shafts into the bloom buffer so the composite stays a three-texture +// read; they are both additive glows and share the same upsample filtering. +def private add_shafts_to_bloom(cfg : PostSettings) { + glUseProgram(up_program) + glEnable(GL_BLEND) + glBlendFunc(uint(GL_ONE), uint(GL_ONE)) + bind_target(bloom_fbo[0], bloom_w[0], bloom_h[0]) + p_tex0 := shaft_tex + p_texel = float2(4.0 / float(fx_width), 4.0 / float(fx_height)) + p_params = float4(cfg.shaft_strength * 1.6, 0.0, 0.0, 0.0) + vs_post_bind_uniform(up_program) + fs_up_bind_uniform(up_program) + fullscreen_pass() + glDisable(GL_BLEND) +} + +def run_postfx(cfg : PostSettings) { + if (scene_fbo == 0u) { + return + } + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glDepthMask(false) + + run_ssao(cfg) + run_bloom(cfg) + if (cfg.shaft_on_screen > 0.0) { + run_shafts(cfg) + add_shafts_to_bloom(cfg) + } + + glUseProgram(composite_program) + bind_target(ldr_fbo, fx_width, fx_height) + p_tex0 := scene_color + p_tex1 := ao_blur_tex + p_tex2 := bloom_tex[0] + p_params = float4(cfg.exposure, cfg.bloom_strength, cfg.ao_apply, cfg.contrast) + p_params2 = float4(cfg.grade_amount, 0.0, 0.0, 0.0) + p_tint = cfg.grade_tint + vs_post_bind_uniform(composite_program) + fs_composite_bind_uniform(composite_program) + fullscreen_pass() + + glUseProgram(present_program) + glBindFramebuffer(GL_FRAMEBUFFER, 0u) + glViewport(0, 0, fx_width, fx_height) + p_tex0 := ldr_tex + p_texel = float2(1.0 / float(fx_width), 1.0 / float(fx_height)) + p_params = float4(cfg.vignette, cfg.vignette_strength, cfg.grain, cfg.time) + p_params2 = float4(0.0, float(fx_height), 0.0, 0.0) + vs_post_bind_uniform(present_program) + fs_present_bind_uniform(present_program) + fullscreen_pass() + + glDepthMask(true) + glEnable(GL_DEPTH_TEST) + glEnable(GL_CULL_FACE) +} diff --git a/examples/games/river_run/rr_shaders.das b/examples/games/river_run/rr_shaders.das new file mode 100644 index 0000000000..ca04cc60aa --- /dev/null +++ b/examples/games/river_run/rr_shaders.das @@ -0,0 +1,543 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require glfw/glfw_boost public +require opengl/opengl_boost public +require opengl/opengl_gen public +require opengl/opengl_cache public +require daslib/math_boost public +require daslib/safe_addr public + +// Scene shading for River Run. +// +// Every surface goes through one deferred-lit forward pass that writes two +// targets: HDR colour, and view-space normal + linear depth for the screen-space +// passes in rr_postfx. The lighting model is a low-cost analytic environment -- +// one sun with a shadow map, a sky hemisphere, and a bounce term tinted by the +// water -- so the section palettes can drive a whole time-of-day per section +// without any authored art. +// +// The shader DSL emits `#version 300 es` on emscripten, so everything here is +// held to the GLSL ES 3.00 / WebGL2 feature set: no compute, no textureGather, +// MRT through `@out @location`, shadow compare through sampler2DShadow. + +// --- Vertex interface (matches opengl_gen's PreviewVertex layout) --- + +var @in @location = 0 v_position : float3 +var @in @location = 1 v_normal : float3 +var @in @location = 2 v_texture : float2 + +var @uniform v_model : float4x4 +var @uniform v_view : float4x4 +var @uniform v_projection : float4x4 +var @uniform v_light_vp : float4x4 + +// --- Varyings --- + +var @inout f_world : float3 +var @inout f_normal : float3 +var @inout f_uv : float2 +var @inout f_view_pos : float3 +var @inout f_ray : float3 + +// --- Environment (host sets these once per frame) --- + +var @uniform f_SunDir : float3 // unit vector pointing TOWARD the sun +var @uniform f_SunColor : float3 +var @uniform f_SkyColor : float3 // zenith +var @uniform f_HorizonColor : float3 +var @uniform f_BounceColor : float3 // light kicked back up off the water +var @uniform f_FogColor : float3 +var @uniform f_FogParams : float3 // x = density, y = height falloff, z = start distance +var @uniform f_CameraPos : float3 +var @uniform f_GameTime : float +var @uniform f_ZFar : float +var @uniform f_NightAmount : float // 0 = day, 1 = full night (stars, dimmer sun) + +// --- Camera basis, for the attributeless sky pass --- + +var @uniform f_CamRight : float3 +var @uniform f_CamUp : float3 +var @uniform f_CamFwd : float3 +var @uniform f_CamScale : float2 // (tan(fov/2) * aspect, tan(fov/2)) + +// --- Per-draw material --- + +var @uniform f_Color : float4 +var @uniform f_Material : float4 // x roughness, y spec strength, z emissive, w rim + +// Composite models (helicopter, gunboat, jet) bake a part index into uv.x and a +// gloss modifier into uv.y, so a whole multi-coloured hull is one draw call +// instead of one per painted panel. +var @uniform f_PaletteOn : float +var @uniform f_Tint0 : float3 +var @uniform f_Tint1 : float3 +var @uniform f_Tint2 : float3 +var @uniform f_Tint3 : float3 + +// --- Shadowing --- + +// Unit 4, not 0: the post chain and the font shader both own unit 0, and GL +// refuses to sample a colour texture through a sampler2DShadow ("bound to wrong +// sampler type (Depth) - using zero texture"), which silently drops shadows. +var @uniform @stage = 4 f_ShadowMap : sampler2DShadow +var @uniform f_ShadowTexel : float2 // 1 / shadow map size + +// --- Water / terrain extras --- + +var @uniform f_RiverColor : float4 +var @uniform f_BankColor : float4 +var @uniform f_ShoreColor : float3 // wet sand / gravel at the waterline +var @uniform f_RockColor : float3 // exposed rock on the steep upper slopes + +// --- Fragment outputs (MRT: HDR colour + view normal & linear depth) --- + +var @out @location = 0 f_FragColor : float4 +var @out @location = 1 f_FragNormalDepth : float4 + +// ============================================================================ +// Shared helpers +// ============================================================================ + +// math::fract and glsl_common::fract both match here, so spell the fractional +// part out; it lowers to the same GLSL either way. +def private frac1(x : float) : float { + return x - floor(x) +} + +def private frac2(p : float2) : float2 { + return p - floor(p) +} + +def private hash21(p : float2) : float { + return frac1(sin(dot(p, float2(127.1, 311.7))) * 43758.545) +} + +// Value noise with a smooth (cubic) interpolant. The bank shading uses this +// instead of the old floor()-quantized hash, which tiled the ground into a +// visible chessboard. +def private vnoise(p : float2) : float { + let i = floor(p) + let f = frac2(p) + let u = f * f * (float2(3.0) - 2.0 * f) + let a = hash21(i) + let b = hash21(i + float2(1.0, 0.0)) + let c = hash21(i + float2(0.0, 1.0)) + let d = hash21(i + float2(1.0, 1.0)) + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y) +} + +def private fbm(p : float2; octaves : int) : float { + var sum = 0.0 + var amp = 0.5 + var freq = p + for (_i in range(octaves)) { + sum += vnoise(freq) * amp + freq *= 2.02 + amp *= 0.5 + } + return sum +} + +// Height-attenuated exponential distance fog. Fading toward the horizon colour +// (not a flat grey) is what makes the far bank read as atmosphere rather than a +// clipped mesh. +def private apply_fog(color : float3; world : float3; view_dist : float) : float3 { + // Fog only starts biting past f_FogParams.z. Without that offset the haze + // greys out the play area itself, which is what made the first pass read as + // washed out rather than atmospheric. + let d = max(view_dist - f_FogParams.z, 0.0) + let height_falloff = exp(-max(world.z, 0.0) * f_FogParams.y) + let amount = saturate(1.0 - exp(-d * f_FogParams.x * height_falloff)) + return lerp(color, f_FogColor, amount) +} + +// 3x3 PCF against the single ortho cascade. The normal-facing slope bias keeps +// near-grazing bank geometry from acne without detaching contact shadows. +def private sun_shadow(world : float3; nrm : float3) : float { + let lp = v_light_vp * float4(world, 1.0) + var proj = lp.xyz / lp.w + proj = proj * 0.5 + float3(0.5) + if (proj.z > 1.0 || proj.x < 0.0 || proj.x > 1.0 || proj.y < 0.0 || proj.y > 1.0) { + return 1.0 + } + let ndl = saturate(dot(nrm, f_SunDir)) + let bias = lerp(0.0035, 0.0006, ndl) + var sum = 0.0 + for (y in range(-1, 2)) { + for (x in range(-1, 2)) { + let ofs = float2(float(x), float(y)) * f_ShadowTexel + sum += textureCompare(f_ShadowMap, proj.xy + ofs, proj.z - bias) + } + } + return sum * (1.0 / 9.0) +} + +// Key + sky hemisphere + water bounce, plus a Blinn specular lobe and a rim +// term. Roughness/spec/emissive/rim ride in f_Material so every prop shares one +// program and one pipeline state. +def private shade_surface(albedo : float3; nrm : float3; world : float3; shadow, spec_scale : float) : float3 { + let view_dir = normalize(f_CameraPos - world) + let ndl = saturate(dot(nrm, f_SunDir)) + + let key = f_SunColor * ndl * shadow + let sky_mix = saturate(nrm.z * 0.5 + 0.5) + let ambient = lerp(f_BounceColor, f_SkyColor, sky_mix) + + let roughness = max(f_Material.x, 0.04) + let shininess = 2.0 / (roughness * roughness) - 2.0 + let half_dir = normalize(f_SunDir + view_dir) + let spec = pow(saturate(dot(nrm, half_dir)), max(shininess, 1.0)) * f_Material.y * spec_scale * shadow * ndl + + let fresnel = pow(1.0 - saturate(dot(nrm, view_dir)), 4.0) + let rim = fresnel * f_Material.w * (f_SkyColor * 0.6 + f_HorizonColor * 0.4) + + return albedo * (key + ambient) + f_SunColor * spec + rim + albedo * f_Material.z +} + +def private encode_normal_depth(nrm : float3) : float4 { + let view_nrm = normalize(float3x3(v_view) * nrm) + return float4(view_nrm * 0.5 + float3(0.5), saturate(-f_view_pos.z / f_ZFar)) +} + +// Procedural sky: zenith-to-horizon ramp, a haze band pinned at the horizon, a +// sun disc with a wide forward-scatter glow, drifting cloud fbm, and stars that +// fade in with f_NightAmount. +def private sky_color(dir : float3) : float3 { + let up = saturate(dir.z) + let horizon_t = pow(1.0 - up, 6.0) + var col = lerp(f_SkyColor, f_HorizonColor, horizon_t) + + let sun_dot = saturate(dot(dir, f_SunDir)) + let glow = pow(sun_dot, 8.0) * 0.35 + pow(sun_dot, 128.0) * 0.9 + col += f_SunColor * glow + let disc = smoothstep(0.9982, 0.9992, sun_dot) + col = lerp(col, f_SunColor * 3.0, disc * (1.0 - f_NightAmount * 0.85)) + + if (dir.z > -0.02) { + let plane = dir.xy / max(dir.z + 0.12, 0.02) + let drift = float2(f_GameTime * 0.012, f_GameTime * 0.004) + let cloud = fbm(plane * 1.9 + drift, 4) + let cover = smoothstep(0.46, 0.72, cloud) * smoothstep(0.0, 0.14, dir.z) + let cloud_lit = lerp(f_HorizonColor, f_SkyColor * 0.4 + f_SunColor * 0.8, saturate(sun_dot + 0.35)) + col = lerp(col, cloud_lit, cover * 0.55) + + // The visible sky is a narrow band, so the star field needs a dense grid + // to land more than a handful of cells in frame. + let star_grid = floor(plane * 95.0) + let star = hash21(star_grid) + let twinkle = 0.65 + 0.35 * sin(f_GameTime * 2.5 + star * 40.0) + let star_lit = smoothstep(0.976, 0.998, star) * twinkle + col += float3(star_lit * f_NightAmount * smoothstep(0.01, 0.16, dir.z)) * float3(0.9, 0.95, 1.0) + } + return col +} + +// ============================================================================ +// Sky — attributeless fullscreen triangle +// ============================================================================ + +[vertex_program] +def vs_sky { + let id = gl_VertexIndex + let uv = float2(float((id << 1) & 2), float(id & 2)) + let ndc = uv * 2.0 - float2(1.0) + f_ray = normalize(f_CamFwd + f_CamRight * (ndc.x * f_CamScale.x) + f_CamUp * (ndc.y * f_CamScale.y)) + gl_Position = float4(ndc, 1.0, 1.0) +} + +[fragment_program] +def fs_sky { + f_FragColor = float4(sky_color(normalize(f_ray)), 1.0) + f_FragNormalDepth = float4(0.5, 0.5, 1.0, 1.0) +} + +// ============================================================================ +// Shadow caster — depth only +// ============================================================================ + +[vertex_program] +def vs_shadow { + gl_Position = v_light_vp * (v_model * float4(v_position, 1.0)) +} + +[fragment_program] +def fs_shadow { + f_FragColor = float4(1.0) +} + +// ============================================================================ +// Props — hull, boats, jets, bridges, trees, houses, pickups +// ============================================================================ + +[vertex_program] +def vs_prop { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_normal = normalize(float3x3(v_model) * v_normal) + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_prop { + let nrm = normalize(f_normal) + var albedo = f_Color.xyz + if (f_PaletteOn > 0.5) { + // Nested step() selects, so the branch stays uniform across the quad. + var picked = f_Tint0 + picked = lerp(picked, f_Tint1, step(0.5, f_uv.x)) + picked = lerp(picked, f_Tint2, step(1.5, f_uv.x)) + picked = lerp(picked, f_Tint3, step(2.5, f_uv.x)) + albedo = picked * f_Color.xyz + } + let shadow = sun_shadow(f_world, nrm) + // uv.y is the part's gloss modifier; 0 reads matte, 1 reads lacquered. + let gloss = (f_PaletteOn > 0.5 ? 0.30 + f_uv.y * 1.6 : 1.0) + let lit = shade_surface(albedo, nrm, f_world, shadow, gloss) + f_FragColor = float4(apply_fog(lit, f_world, length(f_view_pos)), f_Color.w) + f_FragNormalDepth = encode_normal_depth(nrm) +} + +// ============================================================================ +// Terrain — river banks and the centre island of a split +// +// uv.x carries distance from the water's edge in world units (packed by +// gen_river_banks), which drives the sand/grass/rock ramp and lets the shoreline +// stay put as the banks weave. +// ============================================================================ + +[vertex_program] +def vs_terrain { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_normal = normalize(float3x3(v_model) * v_normal) + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_terrain { + let shore_dist = f_uv.x + let p = f_world.xy + + let grain = fbm(p * 0.11, 4) + let macro = fbm(p * 0.021 + float2(37.0, 11.0), 3) + + // Shore and rock are their own palette entries. Deriving them from the bank + // colour, as the first pass did, produced a green beach in every biome. + let sand = f_ShoreColor * (0.86 + grain * 0.30) + let grass = f_BankColor.xyz * (0.78 + grain * 0.46) + let dry = f_BankColor.xyz * float3(1.16, 1.06, 0.78) + let rock = f_RockColor * (0.78 + grain * 0.36) + + // Wet-to-dry sand band right at the waterline, then grass, then rock on the + // steep upper slopes. + var albedo = lerp(sand * 0.72, sand, smoothstep(0.0, 1.1, shore_dist)) + albedo = lerp(albedo, grass, smoothstep(1.4, 4.5, shore_dist)) + albedo = lerp(albedo, dry, saturate(macro * 1.4 - 0.35) * smoothstep(6.0, 26.0, shore_dist)) + + let nrm = normalize(f_normal) + let slope = 1.0 - saturate(nrm.z) + albedo = lerp(albedo, rock, smoothstep(0.55, 0.88, slope)) + + let shadow = sun_shadow(f_world, nrm) + let lit = shade_surface(albedo, nrm, f_world, shadow, 1.0) + f_FragColor = float4(apply_fog(lit, f_world, length(f_view_pos)), 1.0) + f_FragNormalDepth = encode_normal_depth(nrm) +} + +// ============================================================================ +// Water +// +// Three crossing gerstner-ish ripples perturb the normal; the surface then gets +// a Fresnel-weighted sky reflection, a sharp sun glint, depth tinting, and a +// foam band whose width follows uv.x (distance to the nearest bank). +// ============================================================================ + +def private water_normal(p : float2; t : float) : float3 { + var d = float2(0.0) + var amp = 0.055 + var freq = 0.9 + var dir = float2(0.86, 0.5) + for (i in range(3)) { + let phase = dot(p, dir) * freq + t * (1.1 + float(i) * 0.45) + let slope = cos(phase) * amp * freq + d += dir * slope + amp *= 0.55 + freq *= 1.9 + dir = float2(dir.x * 0.28 - dir.y * 0.96, dir.x * 0.96 + dir.y * 0.28) + } + let ripple = (vnoise(p * 2.4 + float2(t * 0.35, -t * 0.22)) - 0.5) * 0.08 + d += float2(ripple, ripple * 0.6) + return normalize(float3(-d.x, -d.y, 1.0)) +} + +[vertex_program] +def vs_water { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_water { + let nrm = water_normal(f_world.xy, f_GameTime) + let view_dir = normalize(f_CameraPos - f_world) + let shore_dist = f_uv.x + + // Shallow water near the banks reads lighter and greener. + let depth_t = smoothstep(0.0, 5.5, shore_dist) + let shallow = f_RiverColor.xyz * 1.55 + float3(0.05, 0.11, 0.06) + let albedo = lerp(shallow, f_RiverColor.xyz * 0.72, depth_t) + + let fresnel = 0.02 + 0.98 * pow(1.0 - saturate(dot(nrm, view_dir)), 5.0) + let refl_dir = reflect(-view_dir, nrm) + let refl = sky_color(normalize(float3(refl_dir.xy, abs(refl_dir.z)))) + + let shadow = sun_shadow(f_world, float3(0.0, 0.0, 1.0)) + let ndl = saturate(dot(nrm, f_SunDir)) + let half_dir = normalize(f_SunDir + view_dir) + let glint = pow(saturate(dot(nrm, half_dir)), 900.0) * shadow + + var col = albedo * (f_SunColor * ndl * 0.35 * shadow + f_SkyColor * 0.55 + f_BounceColor * 0.25) + col = lerp(col, refl, saturate(fresnel * 0.85)) + col += f_SunColor * glint * 1.5 + + // Foam: a bright band hugging the bank, broken up by noise so it does not + // read as a drawn outline, plus a faint moving lace further out. + let foam_edge = 1.0 - smoothstep(0.0, 0.55, shore_dist) + let lace = vnoise(f_world.xy * 5.5 + float2(0.0, f_GameTime * 0.7)) + let churn = vnoise(f_world.xy * 1.7 - float2(f_GameTime * 0.25, 0.0)) + let foam = foam_edge * saturate(0.25 + lace * 1.1) * saturate(0.5 + churn) + col = lerp(col, float3(0.92, 0.96, 1.0) * (f_SkyColor * 0.5 + float3(0.45)), saturate(foam) * 0.8) + + f_FragColor = float4(apply_fog(col, f_world, length(f_view_pos)), 1.0) + f_FragNormalDepth = encode_normal_depth(nrm) +} + +// ============================================================================ +// Rotor — alpha-blended swept disc with per-blade streaks and motion smear +// ============================================================================ + +[vertex_program] +def vs_rotor { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_rotor { + let c = f_uv * 2.0 - float2(1.0) + let r = length(c) + if (r > 1.0) { + discard() + } + let ang = atan2(c.y, c.x) + + // Two blades smeared into a disc: a low-frequency angular ripple plus a + // solid inner haze that stands in for the blur of a spinning rotor. + let blades = 0.5 + 0.5 * cos(ang * 2.0 + f_Material.z * 26.0) + let smear = pow(blades, 3.0) * 0.55 + 0.18 + let ring = smoothstep(0.18, 0.42, r) * (1.0 - smoothstep(0.86, 1.0, r)) + let hub = 1.0 - smoothstep(0.0, 0.22, r) + + let alpha = f_Color.w * saturate(ring * smear + hub * 0.9) + let tint = f_Color.xyz * (0.75 + smear * 0.5) + f_SunColor * 0.12 + f_FragColor = float4(apply_fog(tint, f_world, length(f_view_pos)), alpha) +} + +// ============================================================================ +// Unlit / emissive — bullets, particles, trails, HUD geometry +// ============================================================================ + +[vertex_program] +def vs_unlit { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_unlit { + let col = f_Color.xyz * (1.0 + f_Material.z) + f_FragColor = float4(apply_fog(col, f_world, length(f_view_pos)), f_Color.w) +} + +// ============================================================================ +// Program handles + binding helpers +// ============================================================================ + +var sky_program : uint +var shadow_program : uint +var prop_program : uint +var terrain_program : uint +var water_program : uint +var rotor_program : uint +var unlit_program : uint +var active_program : uint + +def create_scene_programs() { + sky_program = cache_shader_program(vs_sky`shader_text, fs_sky`shader_text) + shadow_program = cache_shader_program(vs_shadow`shader_text, fs_shadow`shader_text) + prop_program = cache_shader_program(vs_prop`shader_text, fs_prop`shader_text) + terrain_program = cache_shader_program(vs_terrain`shader_text, fs_terrain`shader_text) + water_program = cache_shader_program(vs_water`shader_text, fs_water`shader_text) + rotor_program = cache_shader_program(vs_rotor`shader_text, fs_rotor`shader_text) + unlit_program = cache_shader_program(vs_unlit`shader_text, fs_unlit`shader_text) +} + +def use_prop_program() { + glUseProgram(prop_program) + active_program = prop_program +} + +def use_unlit_program() { + glUseProgram(unlit_program) + active_program = unlit_program +} + +// Material presets, so call sites name a surface instead of tuning four floats. +let MAT_MATTE = float4(0.85, 0.05, 0.0, 0.12) +let MAT_HULL = float4(0.34, 0.65, 0.0, 0.35) +let MAT_METAL = float4(0.24, 1.15, 0.0, 0.45) +let MAT_FOLIAGE = float4(0.92, 0.03, 0.0, 0.22) +let MAT_STONE = float4(0.78, 0.10, 0.0, 0.15) +let MAT_GLOW = float4(0.5, 0.4, 0.85, 0.6) + +def draw_prop(pos, scale, color : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0); + material : float4 = MAT_MATTE) { + v_model = compose(pos, rot_quat, scale) + f_Color = float4(color, 1.0) + f_Material = material + vs_prop_bind_uniform(active_program) + fs_prop_bind_uniform(active_program) +} + +def draw_unlit(pos, scale, color : float3; alpha : float = 1.0; + rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0); emissive : float = 0.0) { + v_model = compose(pos, rot_quat, scale) + f_Color = float4(color, alpha) + f_Material = float4(1.0, 0.0, emissive, 0.0) + vs_unlit_bind_uniform(active_program) + fs_unlit_bind_uniform(active_program) +} + +def draw_shadow_caster(pos, scale : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0)) { + v_model = compose(pos, rot_quat, scale) + vs_shadow_bind_uniform(active_program) +} From 27e543340f9482fcf5e63d6b1a8286758a4ad2a3 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 06:58:28 -0700 Subject: [PATCH 03/17] River Run: report window focus and live key state from cmd_game_status Input diagnostics, added while chasing "controls are disabled". They separate a game-logic problem from an activation one in a single query: `focused` is the GLFW window attribute, the rest are live glfwGetKey states. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/rr_live.das | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/examples/games/river_run/rr_live.das b/examples/games/river_run/rr_live.das index 25cf484218..199042c7ce 100644 --- a/examples/games/river_run/rr_live.das +++ b/examples/games/river_run/rr_live.das @@ -20,6 +20,15 @@ struct GameStatusResult { player_y : float fwd_speed : float god_mode : bool + // Input diagnostics: `focused` is the GLFW window attribute, the rest are + // live glfwGetKey states. If focused is false the app is not receiving key + // events at all, which is an activation problem, not a game-logic one. + focused : bool + key_left : bool + key_right : bool + key_up : bool + key_down : bool + key_space : bool boats : int planes : int helis : int @@ -44,7 +53,13 @@ def cmd_game_status(_input : JsonValue?) : JsonValue? { player_x = player_pos.x, player_y = player_pos.y, fwd_speed = player_fwd_speed, - god_mode = god_mode + god_mode = god_mode, + focused = glfwGetWindowAttrib(live_window, int(GLFW_FOCUSED)) != 0, + key_left = glfwGetKey(live_window, GLFW_KEY_LEFT) == GLFW_PRESS, + key_right = glfwGetKey(live_window, GLFW_KEY_RIGHT) == GLFW_PRESS, + key_up = glfwGetKey(live_window, GLFW_KEY_UP) == GLFW_PRESS, + key_down = glfwGetKey(live_window, GLFW_KEY_DOWN) == GLFW_PRESS, + key_space = glfwGetKey(live_window, GLFW_KEY_SPACE) == GLFW_PRESS ) query() $(_b : EnemyBoat) { result.boats++; } query() $(_p : EnemyPlane) { result.planes++; } From 75abad9713434fd64d355c0ad7bfea19966565fc Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 07:18:48 -0700 Subject: [PATCH 04/17] River Run: fix cone winding, the island seam, and rebuild the explosions Three issues from playtesting, plus two reload bugs the work surfaced. geom_gen: gen_cone's SIDE triangles were wound backwards, disagreeing with both their own per-vertex normals and the correctly wound base cap in the same mesh. Under the usual CCW-front / cull-back setup the lit outer surface was culled and the unlit interior showed through. The old river_run hid this with a blanket glDisable(GL_CULL_FACE) around the obstacle pass, which the render rewrite dropped -- so the trees surfaced a latent generator bug. river_run is the only gen_cone caller in the tree. Terrain: the centre island of a split was smooth across x and stepped along y. emit_island computed the far-edge vertex heights from the NEAR slice's half width, so neighbouring slices disagreed about the height of the edge they share. Island vertices also took their normals from profile_normal, which evaluates bank_profile -- the wrong height field entirely. Normals are now emitted per surface at emit time instead of guessed by one after-the-fact pass. Explosions were a colour ramp on flying cubes. A blast is now six layers, each with its own lifetime, motion and blend mode: a white flash at the instant of the hit, fireballs that expand and cool, embers that arc under gravity and streak along their velocity, smoke that rises and outlives everything, solid debris that skids when it hits the water, and a shockwave ring racing outward. Smoke and fire draw as soft camera-facing billboards (vs_puff builds the quad in view space) rather than low-poly spheres, which is what removes the faceted silhouettes. The player's own death runs the same vocabulary at 2.2x. Also fixed: - A reload came back to a black screen. Neither the section environment (plain non-@live globals) nor the geometry handles (GL objects in a context that was just destroyed) survive a reload, and a FULL reload additionally clears the @live river. init() now rebuilds all three unconditionally. - cmd_fx_freeze / cmd_fx_step hold and hand-step the world so an effect can actually be looked at: a screenshot round-trip is far longer than a 75ms flash, and the player otherwise flies 100 units from the blast between one inspection command and the next. cmd_boom detonates on demand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/gameplay.das | 313 ++++++++++++++++++++---- examples/games/river_run/main.das | 22 +- examples/games/river_run/river.das | 82 ++++--- examples/games/river_run/rr_globals.das | 21 ++ examples/games/river_run/rr_live.das | 39 +++ examples/games/river_run/rr_shaders.das | 88 +++++++ modules/dasGlsl/glsl/geom_gen.das | 8 +- 7 files changed, 489 insertions(+), 84 deletions(-) diff --git a/examples/games/river_run/gameplay.das b/examples/games/river_run/gameplay.das index 5f145e0227..5d725b630e 100644 --- a/examples/games/river_run/gameplay.das +++ b/examples/games/river_run/gameplay.das @@ -17,7 +17,10 @@ def spawn_trail(origin, color : float3) { max_life = TRAIL_LIFETIME, size = BULLET_SIZE * 1.1, spin_speed = 0.0, - spin_phase = 0.0 + spin_phase = 0.0, + kind = ParticleKind.trail, + drag = 0.0, + gravity = 0.0 )) } } @@ -43,31 +46,149 @@ def spawn_pickup_burst(origin, color : float3) { p.size = random_range(0.06, 0.14) p.spin_speed = random_range(-15.0, 15.0) p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.ember + p.drag = 2.2 + p.gravity = 5.0 } } -def spawn_particles(origin, color : float3; object_radius : float; count : int) { +// --- Explosions ------------------------------------------------------------- +// +// One blast is six layers, each with its own lifetime, motion and blend mode: +// a single white flash at the instant of the hit, a handful of fireballs that +// expand and cool, embers that arc under gravity, smoke that rises and outlives +// everything, solid debris, and a flat shockwave ring across the water. The +// layering is what turns "boxes flying" into an explosion; no single layer +// carries it alone. + +def private spawn_flash(origin : float3; radius : float) { + create_entities`Particle(1) $(_eid : EntityId; _i : int; var p : Particle) { + p.pos = origin + p.vel = float3(0.0) + p.color = float3(1.0, 0.96, 0.86) + p.lifetime = 0.075 + p.max_life = p.lifetime + p.size = radius * 0.75 + p.spin_speed = 0.0 + p.spin_phase = 0.0 + p.kind = ParticleKind.flash + p.drag = 0.0 + p.gravity = 0.0 + } +} + +def private spawn_fireballs(origin, color : float3; radius : float; count : int) { create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { let angle = random_f() * 2.0 * PI - let r = max(object_radius, 0.18) - let local = float3( - random_range(-r, r), - random_range(-r, r), - random_range(-r * 0.35, r * 0.35) - ) - let away = normalize(local + float3(cos(angle) * 0.35, sin(angle) * 0.35, random_range(-0.25, 0.25))) - let speed = random_range(2.0, 6.5) * (0.7 + r * 0.5) - p.pos = origin + local - p.vel = away * speed + float3(0.0, 0.0, random_range(0.8, 2.8)) + let elev = random_range(-0.2, 0.9) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + p.pos = origin + dir * random_range(0.0, radius * 0.55) + p.vel = dir * random_range(0.6, 2.6) + float3(0.0, 0.0, random_range(0.4, 1.6)) p.color = color - p.lifetime = random_range(PARTICLE_LIFETIME * 0.65, PARTICLE_LIFETIME * 1.25) + p.lifetime = random_range(0.26, 0.46) p.max_life = p.lifetime - p.size = random_range(0.07, 0.26) * (0.7 + r * 0.55) + p.size = radius * random_range(0.22, 0.46) + p.spin_speed = 0.0 + p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.fire + p.drag = 3.4 + p.gravity = -1.2 // hot gas rises + } +} + +def private spawn_embers(origin, color : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let elev = random_range(0.1, 1.5) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + p.pos = origin + dir * radius * 0.3 + p.vel = dir * random_range(5.0, 15.0) * (0.6 + radius * 0.4) + p.color = color + p.lifetime = random_range(0.45, 1.15) + p.max_life = p.lifetime + p.size = random_range(0.035, 0.085) + p.spin_speed = 0.0 + p.spin_phase = 0.0 + p.kind = ParticleKind.ember + p.drag = 1.1 + p.gravity = 9.5 + } +} + +def private spawn_smoke(origin : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let dir = float3(cos(angle), sin(angle), 0.0) + p.pos = origin + dir * random_range(0.0, radius * 0.7) + float3(0.0, 0.0, random_range(0.0, radius * 0.4)) + p.vel = dir * random_range(0.4, 1.6) + float3(0.0, 0.0, random_range(1.1, 2.6)) + // Smoke takes the section's fog colour so it sits in the same air as + // the rest of the scene instead of reading as a grey decal. + p.color = lerp(float3(0.10, 0.09, 0.09), env_now.fog_color, 0.35) + p.lifetime = random_range(1.3, 2.4) + p.max_life = p.lifetime + p.size = radius * random_range(0.24, 0.46) + p.spin_speed = random_range(-1.2, 1.2) + p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.smoke + p.drag = 1.5 + p.gravity = -0.7 + } +} + +def private spawn_debris(origin, color : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let elev = random_range(0.05, 1.1) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + p.pos = origin + dir * radius * 0.4 + p.vel = dir * random_range(3.0, 9.0) * (0.7 + radius * 0.4) + p.color = color + p.lifetime = random_range(0.6, 1.25) + p.max_life = p.lifetime + p.size = random_range(0.035, 0.10) * (0.7 + radius * 0.4) p.spin_speed = random_range(-13.0, 13.0) p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.debris + p.drag = 0.55 + p.gravity = 11.0 + } +} + +def private spawn_shockwave(origin : float3; radius : float) { + create_entities`Particle(1) $(_eid : EntityId; _i : int; var p : Particle) { + // Pinned just above the water so the ring reads as a surface wave + // rather than a sphere seen edge-on. + p.pos = float3(origin.x, origin.y, 0.06) + p.vel = float3(0.0) + p.color = float3(1.0, 0.92, 0.75) + p.lifetime = 0.5 + p.max_life = p.lifetime + p.size = radius * 3.0 + p.spin_speed = 0.0 + p.spin_phase = 0.0 + p.kind = ParticleKind.shock + p.drag = 0.0 + p.gravity = 0.0 } } +// `scale` drives the whole blast: 1.0 is a boat, ~1.8 a fuel depot, and the +// player's own death runs at 2.2 so losing a life lands as an event. +def spawn_explosion(origin, color : float3; object_radius, scale : float) { + let r = max(object_radius, 0.35) * scale + spawn_flash(origin, r) + spawn_fireballs(origin, color, r, int(3.0 + 3.0 * scale)) + spawn_embers(origin, lerp(color, float3(1.0, 0.85, 0.45), 0.5), r, int(10.0 + 12.0 * scale)) + spawn_smoke(origin, r, int(3.0 + 4.0 * scale)) + spawn_debris(origin, color * 0.55, r, int(4.0 + 5.0 * scale)) + spawn_shockwave(origin, r) +} + +// Small sparks with no fireball -- bullet impacts and ricochets. +def spawn_particles(origin, color : float3; object_radius : float; count : int) { + spawn_embers(origin, color, max(object_radius, 0.18), count) +} + def spawn_enemy_boat(pos : float3) { create_entity() @(eid, cmp) { apply_decs_template(cmp, EnemyBoat( @@ -598,7 +719,7 @@ def on_player_killed() { return } player_lives-- - spawn_particles(player_pos, float3(1.0, 0.5, 0.2), PLAYER_SIZE * 2.6, 34) + spawn_explosion(player_pos, float3(1.0, 0.55, 0.18), PLAYER_SIZE * 2.6, 2.2) play_sfx(snd_player_hit) add_screen_shake(SHAKE_MED) if (player_lives > 0) { @@ -837,10 +958,24 @@ def update_bullets() { } // Age out particles -def age_out_particles() { +def step_particles(dt : float) { query() $(eid : EntityId; var p : Particle) { - p.lifetime -= tick_dt - p.pos += p.vel * tick_dt + p.lifetime -= dt + // Drag and gravity ride on the particle, so one integrator covers + // rising smoke, arcing embers and tumbling debris. A negative gravity + // is buoyancy: that is how the hot layers climb. + if (p.drag > 0.0) { + p.vel *= max(1.0 - p.drag * dt, 0.0) + } + if (p.gravity != 0.0) { + p.vel.z -= p.gravity * dt + } + p.pos += p.vel * dt + // Debris that reaches the water stops there and skids out. + if (p.kind == ParticleKind.debris && p.pos.z < 0.05) { + p.pos.z = 0.05 + p.vel = float3(p.vel.x * 0.4, p.vel.y * 0.4, 0.0) + } if (p.lifetime <= 0.0) { delete_entity(eid) } @@ -848,6 +983,13 @@ def age_out_particles() { commit() } +def age_out_particles() { + if (fx_freeze) { + return + } + step_particles(tick_dt) +} + // --- Culling: remove entities far behind player --- def cull_far_entities() { @@ -1105,13 +1247,12 @@ def on_enemy_destroyed(h : EnemyHitInfo; is_depot : bool) { return false } if (is_depot) { - let c1 = int(24.0 + h.blast_radius * 15.0) - let c2 = int(14.0 + h.blast_radius * 10.0) - spawn_particles(h.pos, float3(1.0, 0.5, 0.05), h.blast_radius * 1.2, c1) - spawn_particles(h.pos + float3(0.0, 0.0, 1.0), float3(0.9, 0.2, 0.0), h.blast_radius * 0.8, c2) + // A depot goes up twice: the tank at deck level, then a second bloom + // above it a beat later. + spawn_explosion(h.pos, float3(1.0, 0.52, 0.06), h.blast_radius * 1.2, 1.8) + spawn_explosion(h.pos + float3(0.0, 0.0, 1.1), float3(0.95, 0.28, 0.02), h.blast_radius * 0.8, 1.2) } else { - let c = int(10.0 + h.blast_radius * 12.0) - spawn_particles(h.pos, float3(1.0, 0.65, 0.1), h.blast_radius, c) + spawn_explosion(h.pos, float3(1.0, 0.66, 0.12), h.blast_radius, (is_bridge ? 1.6 : 1.0)) } play_sfx(is_bridge ? snd_bridge_destroy : snd_enemy_explode) if (is_bridge) { @@ -1648,34 +1789,113 @@ def render_bonuses(game_time : float) { // --- Transparent scene --- -def render_projectiles(game_time : float) { +// Explosion layers draw in three passes so they composite correctly: smoke and +// debris alpha-blended first (they occlude), then everything hot additively on +// top, which is also what feeds the bloom. + +def private draw_smoke(game_time : float) { + use_puff_program() + query() $(p : Particle) { + if (p.kind != ParticleKind.smoke) { return ; } + let life = p.lifetime / p.max_life + // Smoke expands as it ages and fades from the far end of its life, so a + // puff thins out instead of blinking off. + let grow = 1.0 + (1.0 - life) * 2.2 + let alpha = saturate(life * 1.5) * 0.5 + let spin = p.spin_phase + p.spin_speed * (1.0 - life) + game_time * 0.08 + draw_puff(p.pos, p.size * grow, p.color, alpha, 0.0, spin, p.spin_phase) + geo_disc |> draw_geometry_fragment() + } use_unlit_program() +} - // Explosion chunks: transparent, spinning, fading to embers. +def private draw_debris() { query() $(p : Particle) { - let is_trail = p.max_life <= TRAIL_LIFETIME + 0.01 - if (is_trail) { return ; } - let heat = p.lifetime / p.max_life - let spin_t = 1.0 - heat - // Debris shrinks and cools as it flies: white-hot at the flash, then - // the pickup/explosion tint, fading out. Without the cooling ramp the - // chunks read as flat orange cardboard. - let s = max(p.size * (0.20 + heat * 0.42), 0.02) - let rot = quat_y_rot(p.spin_phase + p.spin_speed * spin_t) - let hot = lerp(p.color, float3(1.0, 0.94, 0.78), heat * heat) - draw_unlit(p.pos, float3(s), hot, heat * heat * 0.85, rot, heat * heat * 3.2) + if (p.kind != ParticleKind.debris) { return ; } + let life = p.lifetime / p.max_life + let rot = quat_y_rot(p.spin_phase + p.spin_speed * (1.0 - life)) + // Chunks glow briefly then go cold and dark. + let heat = saturate((life - 0.82) * 5.5) + let tint = lerp(p.color * 0.5, float3(1.0, 0.62, 0.24), heat) + draw_unlit(p.pos, float3(p.size), tint, saturate(life * 3.0), rot, heat * 1.6) geo_cube |> draw_geometry_fragment() } +} + +def private draw_hot_layers() { + // Flash and fireballs are soft billboards; only the streaking embers below + // want real geometry. + use_puff_program() + // Flash: one frame-and-a-bit of blowout at the instant of the hit. query() $(p : Particle) { - let is_trail = p.max_life <= TRAIL_LIFETIME + 0.01 - if (!is_trail) { return ; } - let alpha = p.lifetime / p.max_life - let s = max(p.size, BULLET_SIZE) - draw_unlit(p.pos, float3(s), p.color, alpha * 0.6, float4(0.0, 0.0, 0.0, 1.0), 0.4) - geo_sphere |> draw_geometry_fragment() + if (p.kind != ParticleKind.flash) { return ; } + let life = p.lifetime / p.max_life + let grow = 0.5 + (1.0 - life) * 0.9 + draw_puff(p.pos, p.size * grow, p.color, life * life * 0.9, 4.5 * life, 0.0, 0.0) + geo_disc |> draw_geometry_fragment() } + // Fireballs: expand and cool white -> orange -> deep red. + query() $(p : Particle) { + if (p.kind != ParticleKind.fire) { return ; } + let life = p.lifetime / p.max_life + let age = 1.0 - life + let grow = 0.6 + age * 1.05 + var tint = lerp(float3(1.0, 0.48, 0.09), float3(1.0, 0.97, 0.86), saturate(life * 2.6 - 1.6)) + tint = lerp(float3(0.34, 0.045, 0.015), tint, saturate(life * 1.9)) + draw_puff(p.pos, p.size * grow, tint, saturate(life * 1.4), 4.2 * life * life, + p.spin_phase, p.spin_phase * 0.7) + geo_disc |> draw_geometry_fragment() + } + + use_unlit_program() + + // Embers: small, bright, and they streak along their own velocity. + query() $(p : Particle) { + if (p.kind != ParticleKind.ember && p.kind != ParticleKind.trail) { return ; } + let life = p.lifetime / p.max_life + let speed = length(p.vel) + let tint = lerp(float3(0.9, 0.18, 0.04), p.color, saturate(life * 1.4)) + if (p.kind == ParticleKind.ember && speed > 0.5) { + let dir = p.vel / speed + let stretch = clamp(speed * 0.035, 1.0, 4.5) + draw_unlit(p.pos, float3(p.size, p.size, p.size * stretch), tint, + saturate(life * 2.0), quat_align_z(dir), 3.4 * life) + geo_cylinder |> draw_geometry_fragment() + } else { + draw_unlit(p.pos, float3(max(p.size, BULLET_SIZE * 0.8)), tint, + saturate(life * 2.0), float4(0.0, 0.0, 0.0, 1.0), 2.2 * life) + geo_sphere |> draw_geometry_fragment() + } + } +} + +def private draw_shockwaves() { + glUseProgram(ring_program) + active_program = ring_program + query() $(p : Particle) { + if (p.kind != ParticleKind.shock) { return ; } + let life = p.lifetime / p.max_life + let age = 1.0 - life + // The front races out fast and early, then eases; the band narrows as + // it goes, which is what makes it read as travelling rather than growing. + let radius = p.size * (0.10 + sqrt(age) * 0.90) + let width = lerp(0.20, 0.045, age) + v_model = compose(p.pos, float4(0.0, 0.0, 0.0, 1.0), float3(radius, radius, 1.0)) + f_Color = float4(p.color, life * life * 0.55) + f_Material = float4(width, 0.0, 1.1 * life, 0.0) + vs_ring_bind_uniform(active_program) + fs_ring_bind_uniform(active_program) + geo_disc |> draw_geometry_fragment() + } +} + +def render_projectiles(game_time : float) { + use_unlit_program() + draw_smoke(game_time) + draw_debris() + // Player tracers: a bright core with a longer, dimmer streak behind it. query() $(b : PlayerBullet) { let dir = normalize(b.vel) @@ -1704,6 +1924,13 @@ def render_projectiles(game_time : float) { geo_cylinder |> draw_geometry_fragment() } } + + // Additive from here on: fire adds light, it does not occlude. + glBlendFunc(GL_SRC_ALPHA, uint(GL_ONE)) + use_unlit_program() + draw_hot_layers() + draw_shockwaves() + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) } def private draw_disc(center : float3; radius : float; rot : float4; color : float3; alpha, phase : float) { diff --git a/examples/games/river_run/main.das b/examples/games/river_run/main.das index ceee532634..7173279b60 100644 --- a/examples/games/river_run/main.das +++ b/examples/games/river_run/main.das @@ -206,17 +206,27 @@ def init() { score = 0 current_section = 0 cam_x = 0.0 - set_env_immediate(0) init_river() - rebuild_river_geometry() - river_dirty = false - } else { - refresh_env() + } elif (empty(river_segments)) { + // A FULL reload resets every @live variable, so the river is gone even + // though is_reload() is true. Rebuild rather than run on an empty world. + init_river() } + + // Neither of these survives a reload: the section environment lives in + // plain (non-@live) globals, and the geometry handles name GL objects in a + // context that has just been destroyed. Rebuilding both unconditionally is + // what keeps a reload from coming back to a black screen. + set_env_immediate(section_idx()) + rebuild_river_geometry() + river_dirty = false } def simulate() { - if (game_state != GameState.playing) { + // fx_freeze is the effect-inspection rig: it holds the whole world, not just + // the particles, because otherwise the player flies out of the blast's + // postcode between one inspection command and the next. + if (game_state != GameState.playing || fx_freeze) { return } update_section() diff --git a/examples/games/river_run/river.das b/examples/games/river_run/river.das index b1c795db3a..7bc202f31a 100644 --- a/examples/games/river_run/river.das +++ b/examples/games/river_run/river.das @@ -309,15 +309,17 @@ def terrain_height(wx, wy : float) : float { // --- Geometry Rebuild --- -def private push_strip_quad(var frag : GeometryFragment; p0, p1, p2, p3 : float3; uv0, uv1, uv2, uv3 : float2) { +// Corners are given with their own normals. Each surface knows which height +// field it belongs to, so normals are computed at emit time rather than by a +// single after-the-fact pass -- that pass could only guess, and it guessed +// bank_profile for the centre-island vertices too. +def private push_strip_quad(var frag : GeometryFragment; p0, p1, p2, p3 : float3; + n0, n1, n2, n3 : float3; uv0, uv1, uv2, uv3 : float2) { let base = length(frag.vertices) - // Normals are filled in by recompute_normals once the whole strip exists; - // a per-quad face normal would facet the rolling hills. - let zero = float3(0.0) - frag.vertices |> push(GeometryPreviewVertex(xyz = p0, normal = zero, uv = uv0)) // nolint:STYLE012 - appends to the accumulating fragment - frag.vertices |> push(GeometryPreviewVertex(xyz = p1, normal = zero, uv = uv1)) - frag.vertices |> push(GeometryPreviewVertex(xyz = p2, normal = zero, uv = uv2)) - frag.vertices |> push(GeometryPreviewVertex(xyz = p3, normal = zero, uv = uv3)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p0, normal = n0, uv = uv0)) // nolint:STYLE012 - appends to the accumulating fragment + frag.vertices |> push(GeometryPreviewVertex(xyz = p1, normal = n1, uv = uv1)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p2, normal = n2, uv = uv2)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p3, normal = n3, uv = uv3)) frag.indices |> push(base + 0) // nolint:STYLE012 - appends to the accumulating fragment frag.indices |> push(base + 1) frag.indices |> push(base + 2) @@ -357,17 +359,32 @@ def private emit_bank(var frag : GeometryFragment; y0, y1, edge0, edge1, side : let p01 = float3(x01, y0, bank_profile(d1, x01, y0)) let p10 = float3(x10, y1, bank_profile(d0, x10, y1)) let p11 = float3(x11, y1, bank_profile(d1, x11, y1)) + let n00 = profile_normal(d0, x00, y0, side) + let n01 = profile_normal(d1, x01, y0, side) + let n10 = profile_normal(d0, x10, y1, side) + let n11 = profile_normal(d1, x11, y1, side) // Winding depends on which side we are on, so both banks face up. if (side > 0.0) { - push_strip_quad(frag, p00, p01, p10, p11, + push_strip_quad(frag, p00, p01, p10, p11, n00, n01, n10, n11, float2(d0, y0), float2(d1, y0), float2(d0, y1), float2(d1, y1)) } else { - push_strip_quad(frag, p01, p00, p11, p10, + push_strip_quad(frag, p01, p00, p11, p10, n01, n00, n11, n10, float2(d1, y0), float2(d0, y0), float2(d1, y1), float2(d0, y1)) } } } +// Central-difference normal of the island crown, taken through terrain_height so +// it follows the same field the geometry and the gameplay both use. +def private island_normal(wx, wy : float) : float3 { + let e = 0.35 + let hx1 = terrain_height(wx + e, wy) + let hx0 = terrain_height(wx - e, wy) + let hy1 = terrain_height(wx, wy + e) + let hy0 = terrain_height(wx, wy - e) + return normalize(float3(-(hx1 - hx0) / (2.0 * e), -(hy1 - hy0) / (2.0 * e), 1.0)) +} + def private emit_island(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1 : float) { let half0 = (r0 - l0) * 0.5 let half1 = (r1 - l1) * 0.5 @@ -382,18 +399,28 @@ def private emit_island(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1 : fl let x01 = c0 + s * half0 * (1.0 - t1) let x10 = c1 + s * half1 * (1.0 - t0) let x11 = c1 + s * half1 * (1.0 - t1) - let d0 = half0 * t0 - let d1 = half0 * t1 - let p00 = float3(x00, y0, island_profile(d0)) - let p01 = float3(x01, y0, island_profile(d1)) - let p10 = float3(x10, y1, island_profile(d0)) - let p11 = float3(x11, y1, island_profile(d1)) + // Each slice measures distance-from-edge against ITS OWN half width. + // Using the near slice's width for the far edge left neighbouring + // slices disagreeing about the height of the edge they share, which + // stepped the island along y while it stayed smooth across x. + let d00 = half0 * t0 + let d01 = half0 * t1 + let d10 = half1 * t0 + let d11 = half1 * t1 + let p00 = float3(x00, y0, island_profile(d00)) + let p01 = float3(x01, y0, island_profile(d01)) + let p10 = float3(x10, y1, island_profile(d10)) + let p11 = float3(x11, y1, island_profile(d11)) + let n00 = island_normal(x00, y0) + let n01 = island_normal(x01, y0) + let n10 = island_normal(x10, y1) + let n11 = island_normal(x11, y1) if (s > 0.0) { - push_strip_quad(frag, p01, p00, p11, p10, - float2(d1, y0), float2(d0, y0), float2(d1, y1), float2(d0, y1)) + push_strip_quad(frag, p01, p00, p11, p10, n01, n00, n11, n10, + float2(d01, y0), float2(d00, y0), float2(d11, y1), float2(d10, y1)) } else { - push_strip_quad(frag, p00, p01, p10, p11, - float2(d0, y0), float2(d1, y0), float2(d0, y1), float2(d1, y1)) + push_strip_quad(frag, p00, p01, p10, p11, n00, n01, n10, n11, + float2(d00, y0), float2(d01, y0), float2(d10, y1), float2(d11, y1)) } } } @@ -423,15 +450,6 @@ def gen_river_banks() : GeometryFragment { } } - // Fill in the smooth normals now that every vertex position is final. - for (v in frag.vertices) { - let d = v.uv.x - let side = (v.xyz.x >= 0.0 ? 1.0 : -1.0) - v.normal = (v.xyz.z <= 0.0001 && d <= 0.0001 - ? float3(0.0, 0.0, 1.0) - : profile_normal(d, v.xyz.x, v.xyz.y, side)) - } - gen_bbox(frag) return <- frag } @@ -457,9 +475,11 @@ def private emit_water_channel(var frag : GeometryFragment; y0, y1, l0, r0, l1, let d01 = min(x01 - l0, r0 - x01) let d10 = min(x10 - l1, r1 - x10) let d11 = min(x11 - l1, r1 - x11) + let up = float3(0.0, 0.0, 1.0) push_strip_quad(frag, float3(x00, y0, z), float3(x01, y0, z), float3(x10, y1, z), float3(x11, y1, z), + up, up, up, up, float2(d00, y0), float2(d01, y0), float2(d10, y1), float2(d11, y1)) } } @@ -491,10 +511,6 @@ def gen_river_surface() : GeometryFragment { } } - for (v in frag.vertices) { - v.normal = float3(0.0, 0.0, 1.0) - } - gen_bbox(frag) return <- frag } diff --git a/examples/games/river_run/rr_globals.das b/examples/games/river_run/rr_globals.das index 6aac6d92a4..8a4fce734c 100644 --- a/examples/games/river_run/rr_globals.das +++ b/examples/games/river_run/rr_globals.das @@ -242,6 +242,19 @@ struct BonusPickup { bob_phase : float } +// Explosions are layered, and each layer moves and draws differently, so the +// particle carries its kind rather than having the renderer infer one from a +// lifetime threshold. +enum ParticleKind { + trail // bullet tracer dots + debris // spinning solid chunks, gravity + ember // small bright sparks, gravity, additive + smoke // dark puff, rises and expands, outlives everything else + fire // expanding fireball, cools white -> orange -> red + flash // one very short white blowout at the instant of the hit + shock // flat ring racing outward across the water +} + [decs_template] struct Particle { pos : float3 @@ -252,6 +265,9 @@ struct Particle { size : float spin_speed : float spin_phase : float + kind : ParticleKind + drag : float + gravity : float } // --- River Segment --- @@ -316,6 +332,11 @@ var @live river_first_split_pending = true var @live river_first_split_start_y = 0.0 var tick_dt = 0.0 +// Effect-inspection rig: with fx_freeze set, the world and its particles both +// stop, so a blast can be held still and stepped by hand (cmd_fx_freeze / +// cmd_fx_step). A screenshot round-trip is far longer than a 100ms flash, so +// there is no other way to actually look at one. +var fx_freeze = false var global_rng_seed = 12345u var display_w = 0 var display_h = 0 diff --git a/examples/games/river_run/rr_live.das b/examples/games/river_run/rr_live.das index 199042c7ce..cbaa5aaa9e 100644 --- a/examples/games/river_run/rr_live.das +++ b/examples/games/river_run/rr_live.das @@ -141,6 +141,45 @@ def cmd_set_section(input : JsonValue?) : JsonValue? { return JV(args) } +struct CmdBoomArgs { + ahead : float = 14.0 + offset : float = 0.0 + height : float = 1.0 + scale : float = 1.0 +} + +[live_command(description = "Detonate an explosion ahead of the player, for tuning. Args: ahead, offset, height, scale")] +def cmd_boom(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + let at = float3(player_pos.x + args.offset, player_pos.y + args.ahead, args.height) + spawn_explosion(at, float3(1.0, 0.62, 0.12), 1.0, args.scale) + add_screen_shake(SHAKE_MED) + commit() + return JV(args) +} + +struct CmdFxFreezeArgs { + enabled : bool = true +} + +[live_command(description = "Halt particle ageing so a blast can be inspected frame by frame. Args: enabled")] +def cmd_fx_freeze(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + fx_freeze = args.enabled + return JV(args) +} + +struct CmdFxStepArgs { + dt : float = 0.05 +} + +[live_command(description = "Advance frozen particles by dt seconds. Args: dt")] +def cmd_fx_step(input : JsonValue?) : JsonValue? { + let args = from_JV(input, type) + step_particles(args.dt) + return JV(args) +} + enum SpawnKind { boat plane diff --git a/examples/games/river_run/rr_shaders.das b/examples/games/river_run/rr_shaders.das index ca04cc60aa..10557e3203 100644 --- a/examples/games/river_run/rr_shaders.das +++ b/examples/games/river_run/rr_shaders.das @@ -459,6 +459,76 @@ def fs_rotor { f_FragColor = float4(apply_fog(tint, f_world, length(f_view_pos)), alpha) } +// ============================================================================ +// Soft puff -- a camera-facing billboard with a radial falloff +// +// Smoke and fireballs were low-poly spheres, which gave every puff a hard +// faceted silhouette. A billboard with a soft edge is both cheaper and reads as +// a volume rather than a solid. The quad is built in VIEW space so it always +// faces the camera without the host computing a basis; f_Material carries the +// radius, the emissive and the spin angle. +// ============================================================================ + +[vertex_program] +def vs_puff { + let center = v_view * (v_model * float4(0.0, 0.0, 0.0, 1.0)) + let r = f_Material.y + let view_pos = center.xyz + float3(v_position.x * r, v_position.y * r, 0.0) + f_view_pos = view_pos + f_uv = v_texture + f_world = (v_model * float4(0.0, 0.0, 0.0, 1.0)).xyz + gl_Position = v_projection * float4(view_pos, 1.0) +} + +[fragment_program] +def fs_puff { + var c = f_uv * 2.0 - float2(1.0) + // Spin the sampling frame so repeated puffs do not read as clones. + let a = f_Material.w + let ca = cos(a) + let sa = sin(a) + c = float2(c.x * ca - c.y * sa, c.x * sa + c.y * ca) + let r2 = dot(c, c) + if (r2 > 1.0) { + discard() + } + // A lumpy edge, so the billboard does not read as a perfect circle. + let lump = 0.82 + 0.18 * vnoise(c * 2.4 + float2(f_Material.x * 7.3)) + let falloff = pow(saturate(1.0 - r2 / (lump * lump)), 1.7) + let alpha = f_Color.w * falloff + let col = f_Color.xyz * (1.0 + f_Material.z * falloff) + f_FragColor = float4(apply_fog(col, f_world, length(f_view_pos)), alpha) +} + +// ============================================================================ +// Shockwave ring -- a flat annulus on the water, drawn additively +// ============================================================================ + +[vertex_program] +def vs_ring { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_ring { + let c = f_uv * 2.0 - float2(1.0) + let r = length(c) + if (r > 1.0) { + discard() + } + // f_Material.x is the ring's normalised thickness; the band tightens and + // dims as the wave expands, which is what sells it as a travelling front. + let width = max(f_Material.x, 0.01) + let band = 1.0 - saturate(abs(1.0 - r) / width) + let alpha = f_Color.w * band * band + f_FragColor = float4(f_Color.xyz * (1.0 + f_Material.z), alpha) +} + // ============================================================================ // Unlit / emissive — bullets, particles, trails, HUD geometry // ============================================================================ @@ -488,6 +558,8 @@ var prop_program : uint var terrain_program : uint var water_program : uint var rotor_program : uint +var puff_program : uint +var ring_program : uint var unlit_program : uint var active_program : uint @@ -498,6 +570,8 @@ def create_scene_programs() { terrain_program = cache_shader_program(vs_terrain`shader_text, fs_terrain`shader_text) water_program = cache_shader_program(vs_water`shader_text, fs_water`shader_text) rotor_program = cache_shader_program(vs_rotor`shader_text, fs_rotor`shader_text) + puff_program = cache_shader_program(vs_puff`shader_text, fs_puff`shader_text) + ring_program = cache_shader_program(vs_ring`shader_text, fs_ring`shader_text) unlit_program = cache_shader_program(vs_unlit`shader_text, fs_unlit`shader_text) } @@ -511,6 +585,20 @@ def use_unlit_program() { active_program = unlit_program } +def use_puff_program() { + glUseProgram(puff_program) + active_program = puff_program +} + +// `seed` only varies the edge lumpiness and spin between puffs. +def draw_puff(pos : float3; radius : float; color : float3; alpha, emissive, spin, seed : float) { + v_model = compose(pos, float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + f_Color = float4(color, alpha) + f_Material = float4(seed, radius, emissive, spin) + vs_puff_bind_uniform(active_program) + fs_puff_bind_uniform(active_program) +} + // Material presets, so call sites name a surface instead of tuning four floats. let MAT_MATTE = float4(0.85, 0.05, 0.0, 0.12) let MAT_HULL = float4(0.34, 0.65, 0.0, 0.35) diff --git a/modules/dasGlsl/glsl/geom_gen.das b/modules/dasGlsl/glsl/geom_gen.das index d66109adc0..c00140bea5 100644 --- a/modules/dasGlsl/glsl/geom_gen.das +++ b/modules/dasGlsl/glsl/geom_gen.das @@ -264,11 +264,15 @@ def gen_cone(plt : GenDirection; sectorCount : int) { uv = float2(0.5, 0.5) )) - // side triangles + // Side triangles, wound counter-clockwise seen from OUTSIDE the cone, which + // is what the per-vertex normals above already claim and what the base cap + // below already does. The (i, tip, i+1) order was inverted, so under the + // usual CCW-front / cull-back setup the lit outer surface was culled and the + // unlit interior showed through. for (i in range(sectorCount)) { frag.indices |> push(i) // nolint:STYLE012 — appends one side triangle per sector to the running index buffer - frag.indices |> push(tip) frag.indices |> push(i + 1) + frag.indices |> push(tip) } // base triangles From 62d3fc961b50442c9cf4bbde16b429e0f0d16c82 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 08:12:30 -0700 Subject: [PATCH 05/17] River Run: fix cylinder winding, taper the island lengthwise, add metal reflections geom_gen: gen_cylinder was inverted on all three parts -- the side quads and BOTH caps -- so a cylinder rendered as its own interior wherever back faces are culled. Same class of defect as gen_cone last round; that pair is the whole circle-based family. The fuel depot's tank is the visible case here, but examples/opengl/07_hello_gen.das culls too and was equally wrong. Terrain: the centre island only ever tapered sideways, so it met open water at full crown height and left a ridge running down the river -- the seam that survived the previous fix. Height now takes min(lateral, longitudinal) distance, where the longitudinal term is the distance along y to the nearest end of the split run, so the crown rounds off in every direction. A run that continues past the generated river reports a large distance instead of tapering, otherwise the island would grow a moving wall at the far end as new segments scroll in. Shading: props can now mix in a sampled environment colour, which is what makes a surface read as metal rather than as painted plastic with a highlight on it. sky_env is a cheap probe -- the sky gradient and the sun, no cloud fbm or star field, plus a ground half so a cylinder does not mirror sky out of its underside. draw_prop takes a (reflectivity, head-on bias) pair with NONE / PAINT / METAL / CHROME presets, and anything in the model palette's metal slot reflects automatically, so the boats, jets and helicopters pick it up without their call sites opting in. The depot tank is chrome. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/gameplay.das | 14 ++--- examples/games/river_run/river.das | 69 +++++++++++++++++++++++-- examples/games/river_run/rr_shaders.das | 55 +++++++++++++++++++- modules/dasGlsl/glsl/geom_gen.das | 17 +++--- 4 files changed, 135 insertions(+), 20 deletions(-) diff --git a/examples/games/river_run/gameplay.das b/examples/games/river_run/gameplay.das index 5d725b630e..cfd0cfe9e4 100644 --- a/examples/games/river_run/gameplay.das +++ b/examples/games/river_run/gameplay.das @@ -1641,7 +1641,7 @@ def render_bridges() { let pylon_h = BRIDGE_SPAN_Z + 0.5 for (px in fixed_array(br.left_x, br.right_x)) { draw_prop(float3(px, br.pos.y, pylon_h * 0.5), float3(0.4, 0.3, pylon_h), - pylon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + pylon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE, REFLECT_PAINT) geo_cube |> draw_geometry_fragment() } @@ -1693,14 +1693,16 @@ def render_fuel_depots(game_time : float) { let beacon_pulse = 0.55 + 0.45 * abs(sin(game_time * 3.5)) draw_prop(d.pos, float3(FUEL_DEPOT_SIZE, FUEL_DEPOT_SIZE * 1.8, FUEL_DEPOT_SIZE * 0.25), - float3(0.30, 0.31, 0.33), float4(0.0, 0.0, 0.0, 1.0), MAT_METAL) + float3(0.30, 0.31, 0.33), float4(0.0, 0.0, 0.0, 1.0), MAT_METAL, REFLECT_PAINT) geo_cube |> draw_geometry_fragment() // Storage tank lying on the deck; the cylinder gives the depot a - // silhouette you can pick out of the bank clutter at range. + // silhouette you can pick out of the bank clutter at range, and the + // chrome reflection is what makes it read as a pressure vessel rather + // than a painted drum. draw_prop(d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.45), float3(FUEL_DEPOT_SIZE * 0.42, FUEL_DEPOT_SIZE * 0.42, FUEL_DEPOT_SIZE * 0.95), - float3(0.62, 0.58, 0.20), quat_x_rot(PI * 0.5), MAT_METAL) + float3(0.70, 0.66, 0.30), quat_x_rot(PI * 0.5), MAT_METAL, REFLECT_CHROME) geo_cylinder |> draw_geometry_fragment() let beacon_color = ( @@ -1708,7 +1710,7 @@ def render_fuel_depots(game_time : float) { ) * beacon_pulse draw_prop(d.pos + float3(0.0, FUEL_DEPOT_SIZE * 0.9, FUEL_DEPOT_SIZE * 0.9), float3(0.09, 0.09, FUEL_DEPOT_SIZE * 0.55), float3(0.22, 0.22, 0.24), - float4(0.0, 0.0, 0.0, 1.0), MAT_METAL) + float4(0.0, 0.0, 0.0, 1.0), MAT_METAL, REFLECT_METAL) geo_cube |> draw_geometry_fragment() draw_prop(d.pos + float3(0.0, FUEL_DEPOT_SIZE * 0.9, FUEL_DEPOT_SIZE * 1.5), float3(0.16), beacon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_GLOW) @@ -1780,7 +1782,7 @@ def render_bonuses(game_time : float) { let color = bonus_color(b.bonus_type) let spin = quat_z_rot(game_time * BONUS_SPIN_SPEED + b.bob_phase) let rot = quat_mul(spin, q_tilt) - draw_prop(p, float3(0.24, 0.24, 0.52), color, rot, MAT_GLOW) + draw_prop(p, float3(0.24, 0.24, 0.52), color, rot, MAT_GLOW, REFLECT_METAL) geo_cylinder |> draw_geometry_fragment() draw_prop(p, float3(0.30), color * 1.4, rot, MAT_GLOW) geo_sphere |> draw_geometry_fragment() diff --git a/examples/games/river_run/river.das b/examples/games/river_run/river.das index 7bc202f31a..88386f0632 100644 --- a/examples/games/river_run/river.das +++ b/examples/games/river_run/river.das @@ -290,6 +290,62 @@ def private island_profile(d : float) : float { return smoothstep(0.0, 1.4, d) * 0.55 + smoothstep(0.6, 3.2, d) * 0.7 } +// Distance along y to the nearest END of the split run containing world_y. +// +// The banks only ever have to blend sideways -- they run the whole length of +// the river. The centre island does not: it starts and stops, and if its height +// only tapers laterally it meets open water at full crown height, which is the +// seam that runs down the river. Feeding min(lateral, longitudinal) into the +// same profile rounds the ends off too, so the island blends in every +// direction. +// +// A run that continues past the generated river returns a large distance rather +// than tapering, otherwise the island would grow a moving wall at the far end +// as new segments scroll in. +let ISLAND_OPEN_END = 1.0e6 + +def island_end_distance(world_y : float) : float { + let n = length(river_segments) + if (n < 2) { + return 0.0 + } + var here = -1 + for (i in range(n - 1)) { + if (river_segments[i].world_y <= world_y && world_y < river_segments[i + 1].world_y) { + here = i + break + } + } + if (here < 0) { + return ISLAND_OPEN_END + } + + var ahead = ISLAND_OPEN_END + for (step in range(n)) { + let i = here + 1 + step + if (i >= n) { + break + } + if (!river_segments[i].split) { + ahead = max(river_segments[i].world_y - world_y, 0.0) + break + } + } + + var behind = ISLAND_OPEN_END + for (step in range(n)) { + let i = here - step + if (i < 0) { + break + } + if (!river_segments[i].split) { + behind = max(world_y - river_segments[i].world_y, 0.0) + break + } + } + return min(ahead, behind) +} + // Ground height at any world position: 0 on open water, the bank profile // outside the channel, a low crown on the centre island of a split. def terrain_height(wx, wy : float) : float { @@ -302,7 +358,7 @@ def terrain_height(wx, wy : float) : float { } if (s.split && wx >= s.split_left_x && wx <= s.split_right_x) { let d = min(wx - s.split_left_x, s.split_right_x - wx) - return island_profile(d) + return island_profile(min(d, island_end_distance(wy))) } return 0.0 } @@ -390,6 +446,9 @@ def private emit_island(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1 : fl let half1 = (r1 - l1) * 0.5 let c0 = (l0 + r0) * 0.5 let c1 = (l1 + r1) * 0.5 + // Longitudinal distance to the run's ends, so the crown rounds off there. + let end0 = island_end_distance(y0) + let end1 = island_end_distance(y1) for (i in range(ISLAND_SPANS)) { let t0 = float(i) / float(ISLAND_SPANS) let t1 = float(i + 1) / float(ISLAND_SPANS) @@ -403,10 +462,10 @@ def private emit_island(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1 : fl // Using the near slice's width for the far edge left neighbouring // slices disagreeing about the height of the edge they share, which // stepped the island along y while it stayed smooth across x. - let d00 = half0 * t0 - let d01 = half0 * t1 - let d10 = half1 * t0 - let d11 = half1 * t1 + let d00 = min(half0 * t0, end0) + let d01 = min(half0 * t1, end0) + let d10 = min(half1 * t0, end1) + let d11 = min(half1 * t1, end1) let p00 = float3(x00, y0, island_profile(d00)) let p01 = float3(x01, y0, island_profile(d01)) let p10 = float3(x10, y1, island_profile(d10)) diff --git a/examples/games/river_run/rr_shaders.das b/examples/games/river_run/rr_shaders.das index 10557e3203..7634ad3bb6 100644 --- a/examples/games/river_run/rr_shaders.das +++ b/examples/games/river_run/rr_shaders.das @@ -66,6 +66,10 @@ var @uniform f_CamScale : float2 // (tan(fov/2) * aspect, tan(fov/2)) var @uniform f_Color : float4 var @uniform f_Material : float4 // x roughness, y spec strength, z emissive, w rim +// x reflectivity, y head-on bias. Reflectivity mixes in a sampled environment +// colour, which is what makes a surface read as metal rather than as painted +// plastic with a highlight on it. +var @uniform f_Material2 : float4 // Composite models (helicopter, gunboat, jet) bake a part index into uv.x and a // gloss modifier into uv.y, so a whole multi-coloured hull is one draw call @@ -201,6 +205,25 @@ def private encode_normal_depth(nrm : float3) : float4 { return float4(view_nrm * 0.5 + float3(0.5), saturate(-f_view_pos.z / f_ZFar)) } +// A cheap environment probe for reflective surfaces: the sky gradient and the +// sun, without the cloud fbm or the star field sky_color pays for. On a small +// curved prop the clouds would not read anyway, and this runs per pixel on +// every metal surface in the frame. +def private sky_env(dir : float3) : float3 { + let up = dir.z + if (up < 0.0) { + // Reflecting downward: water and ground bounce, brightening toward the + // horizon. Without this a metal cylinder mirrors sky out of its + // underside. + return lerp(f_HorizonColor * 0.5, f_BounceColor * 2.2, saturate(-up * 1.6)) + } + let horizon_t = pow(1.0 - up, 6.0) + var col = lerp(f_SkyColor, f_HorizonColor, horizon_t) + let sun_dot = saturate(dot(dir, f_SunDir)) + col += f_SunColor * (pow(sun_dot, 12.0) * 0.45 + pow(sun_dot, 300.0) * 2.2) + return col +} + // Procedural sky: zenith-to-horizon ramp, a haze band pinned at the horizon, a // sun disc with a wide forward-scatter glow, drifting cloud fbm, and stars that // fade in with f_NightAmount. @@ -297,7 +320,25 @@ def fs_prop { let shadow = sun_shadow(f_world, nrm) // uv.y is the part's gloss modifier; 0 reads matte, 1 reads lacquered. let gloss = (f_PaletteOn > 0.5 ? 0.30 + f_uv.y * 1.6 : 1.0) - let lit = shade_surface(albedo, nrm, f_world, shadow, gloss) + var lit = shade_surface(albedo, nrm, f_world, shadow, gloss) + + // Anything in the model palette's metal slot reflects by default, so a whole + // fleet reads as metal without every call site opting in. + var reflectivity = f_Material2.x + if (f_PaletteOn > 0.5) { + let is_metal = step(1.5, f_uv.x) * (1.0 - step(2.5, f_uv.x)) + reflectivity = max(reflectivity, is_metal * 0.5) + } + if (reflectivity > 0.002) { + let view_dir = normalize(f_CameraPos - f_world) + let env = sky_env(normalize(reflect(-view_dir, nrm))) + let fres = pow(1.0 - saturate(dot(nrm, view_dir)), 4.0) + // Metal tints what it reflects; the grazing term keeps flat-on faces + // from turning into mirrors. + let mixed = env * lerp(float3(1.0), albedo, 0.65) + lit = lerp(lit, mixed, saturate(reflectivity * (f_Material2.y + (1.0 - f_Material2.y) * fres))) + } + f_FragColor = float4(apply_fog(lit, f_world, length(f_view_pos)), f_Color.w) f_FragNormalDepth = encode_normal_depth(nrm) } @@ -607,11 +648,21 @@ let MAT_FOLIAGE = float4(0.92, 0.03, 0.0, 0.22) let MAT_STONE = float4(0.78, 0.10, 0.0, 0.15) let MAT_GLOW = float4(0.5, 0.4, 0.85, 0.6) +// Reflectivity presets for the draw_prop `reflect_amt` argument. +let REFLECT_NONE = float2(0.0, 0.25) +let REFLECT_PAINT = float2(0.22, 0.10) +let REFLECT_METAL = float2(0.62, 0.30) +let REFLECT_CHROME = float2(0.90, 0.55) + +// `reflect_amt` is (reflectivity, head-on bias): x is how much environment the +// surface mixes in at all, y how much of that survives when looking straight at +// it. Chrome wants a high bias, painted metal a low one. def draw_prop(pos, scale, color : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0); - material : float4 = MAT_MATTE) { + material : float4 = MAT_MATTE; reflect_amt : float2 = float2(0.0, 0.25)) { v_model = compose(pos, rot_quat, scale) f_Color = float4(color, 1.0) f_Material = material + f_Material2 = float4(reflect_amt.x, reflect_amt.y, 0.0, 0.0) vs_prop_bind_uniform(active_program) fs_prop_bind_uniform(active_program) } diff --git a/modules/dasGlsl/glsl/geom_gen.das b/modules/dasGlsl/glsl/geom_gen.das index c00140bea5..329671ef2f 100644 --- a/modules/dasGlsl/glsl/geom_gen.das +++ b/modules/dasGlsl/glsl/geom_gen.das @@ -186,35 +186,38 @@ def gen_cylinder(plt : GenDirection; sectorCount : int) { } } apply_gen_direction_tm(plt, frag) - // indices + // Indices, wound counter-clockwise seen from OUTSIDE, to agree with the + // per-vertex normals set above. The side quads and BOTH caps were inverted, + // so under the usual CCW-front / cull-back setup a cylinder rendered as its + // own interior. for (i in range(sectorCount)) { let k1 = i let k2 = i + sectorCount + 2 // triangle 1 frag.indices |> push(k1) // nolint:STYLE012 — appends two triangles per sector to the running index buffer - frag.indices |> push(k2) frag.indices |> push(k1 + 1) + frag.indices |> push(k2) // triangle 2 frag.indices |> push(k2) - frag.indices |> push(k2 + 1) frag.indices |> push(k1 + 1) + frag.indices |> push(k2 + 1) } - // top + // cap at h = -1 (normal -z) let tbOfs = (sectorCount + 2) * 2 let kc1 = sectorCount + 1 + tbOfs for (i in range(sectorCount)) { let k = i + tbOfs frag.indices |> push(kc1) // nolint:STYLE012 — appends one cap triangle per sector to the running index buffer - frag.indices |> push(k) frag.indices |> push(k + 1) + frag.indices |> push(k) } - // bottom + // cap at h = +1 (normal +z) let kc2 = ((sectorCount + 2) + sectorCount + 1) + tbOfs for (i in range(sectorCount)) { let k = (sectorCount + 2) + i + tbOfs frag.indices |> push(kc2) // nolint:STYLE012 — appends one cap triangle per sector to the running index buffer - frag.indices |> push(k + 1) frag.indices |> push(k) + frag.indices |> push(k + 1) } delete unitVertices frag.prim = GeometryFragmentType.triangles From 7ec60f19324f1efa379fdaad5daf8965b4eafacf Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 08:19:03 -0700 Subject: [PATCH 06/17] River Run: kill the split seam in the water, stop pause from dimming the scene The seam that survived two rounds of terrain fixes was never in the terrain. fs_water shades shallow-vs-deep from uv.x = distance to shore, and inside a split that was measured against the CHANNEL edges the geometry is built from -- so a whole narrow channel read as shallow, then flipped to deep the moment the per-segment `split` flag went false. One slice, one hard line straight across the river. Distance-to-shore is now a property of the water, not of the mesh it happens to be built from: the centre island shallows the water only in proportion to how much island is actually there. Its crown already tapers to nothing at the ends of a run, so the water stops pretending there is a shore mid-channel exactly as the island stops being one, and the transition is smooth in both directions. Pause no longer draws a full-screen scrim. Pausing is how you stop and look at something; dimming the frame hides the very thing you paused to inspect. Game over and the win screen keep theirs, because there the message IS what you are meant to be reading. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/hud.das | 15 +++++++++- examples/games/river_run/river.das | 47 +++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/examples/games/river_run/hud.das b/examples/games/river_run/hud.das index d22cb867b5..b89a90a4ae 100644 --- a/examples/games/river_run/hud.das +++ b/examples/games/river_run/hud.das @@ -147,6 +147,19 @@ def private draw_overlay_card(title : string; title_color : float3; sub, hint : draw_text_centered(hint, cx, cy + 62.0, TEXT_BODY, float3(0.80, 0.86, 0.92) * blink) } +// Pause deliberately does NOT dim the scene. Pausing is how you stop and look +// at something -- a dimming scrim over the whole frame hides the very thing you +// paused to inspect. Game over and the win screen keep their scrim, because +// there the message IS what you are meant to be reading. +def private draw_pause_banner() { + let cx = design_w() * 0.5 + let blink = 0.6 + 0.4 * abs(sin(get_uptime() * 2.6)) + draw_text_centered("PAUSED", cx, design_h() * 0.5 - 18.0, TEXT_HEAD, + float3(1.0, 0.88, 0.35) * blink) + draw_text_centered("ESC to resume", cx, design_h() * 0.5 + 14.0, TEXT_SMALL, + float3(0.72, 0.78, 0.86)) +} + def draw_hud() { if (hud_font == null) { return @@ -165,7 +178,7 @@ def draw_hud() { draw_section_banner() if (game_state == GameState.paused) { - draw_overlay_card("PAUSED", float3(1.0, 0.88, 0.35), "", "PRESS ESC TO RESUME") + draw_pause_banner() } elif (game_state == GameState.game_over_state) { draw_overlay_card("GAME OVER", float3(1.0, 0.32, 0.28), "SCORE {score}", "PRESS SPACE TO RESTART") diff --git a/examples/games/river_run/river.das b/examples/games/river_run/river.das index 88386f0632..4b18cb9f08 100644 --- a/examples/games/river_run/river.das +++ b/examples/games/river_run/river.das @@ -513,11 +513,38 @@ def gen_river_banks() : GeometryFragment { return <- frag } -// Water is subdivided laterally so uv.x can carry distance-to-nearest-bank; the -// shader turns that into the shallow-water tint and the foam band. +// Water is subdivided laterally so uv.x can carry distance-to-shore; the shader +// turns that into the shallow-water tint and the foam band. let WATER_SPANS = 10 -def private emit_water_channel(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1, z : float) { +// How far this point is from shore, for shading purposes. +// +// Measuring against the CHANNEL edges inside a split -- which is what the +// geometry is built from -- made the entire narrow channel read as shallow, and +// then flip to deep in a single slice the moment `split` went false. That is a +// hard line straight across the river. +// +// The centre island only shallows the water in proportion to how much island is +// actually there. Its crown tapers to nothing at the ends of a split run, so the +// water stops pretending there is a shore mid-channel exactly as the island +// stops being one, and the transition is smooth in both directions. +def private water_shore_dist(wx, wy : float; s : RiverSegment) : float { + let nearest_bank = min(wx - s.left_bank_x, s.right_bank_x - wx) + let d_bank = clamp(nearest_bank, 0.0, RIVER_HALF_WIDTH_MAX + TERRAIN_EXTEND) + if (!s.split) { + return d_bank + } + let half = (s.split_right_x - s.split_left_x) * 0.5 + let crown = island_profile(min(half, island_end_distance(wy))) + let presence = saturate(crown / 0.45) + let d_isl = min(abs(wx - s.split_left_x), abs(wx - s.split_right_x)) + return max(lerp(d_bank, min(d_bank, d_isl), presence), 0.0) +} + +def private emit_water_channel(var frag : GeometryFragment; s0, s1 : RiverSegment; + l0, r0, l1, r1, z : float) { + let y0 = s0.world_y + let y1 = s1.world_y let w0 = r0 - l0 let w1 = r1 - l1 if (w0 <= 0.01 && w1 <= 0.01) { @@ -530,10 +557,10 @@ def private emit_water_channel(var frag : GeometryFragment; y0, y1, l0, r0, l1, let x01 = l0 + w0 * t1 let x10 = l1 + w1 * t0 let x11 = l1 + w1 * t1 - let d00 = min(x00 - l0, r0 - x00) - let d01 = min(x01 - l0, r0 - x01) - let d10 = min(x10 - l1, r1 - x10) - let d11 = min(x11 - l1, r1 - x11) + let d00 = water_shore_dist(x00, y0, s0) + let d01 = water_shore_dist(x01, y0, s0) + let d10 = water_shore_dist(x10, y1, s1) + let d11 = water_shore_dist(x11, y1, s1) let up = float3(0.0, 0.0, 1.0) push_strip_quad(frag, float3(x00, y0, z), float3(x01, y0, z), @@ -560,12 +587,12 @@ def gen_river_surface() : GeometryFragment { let s0 = river_segments[i] let s1 = river_segments[i + 1] if (s0.split || s1.split) { - emit_water_channel(frag, s0.world_y, s1.world_y, + emit_water_channel(frag, s0, s1, s0.left_bank_x, s0.split_left_x, s1.left_bank_x, s1.split_left_x, z) - emit_water_channel(frag, s0.world_y, s1.world_y, + emit_water_channel(frag, s0, s1, s0.split_right_x, s0.right_bank_x, s1.split_right_x, s1.right_bank_x, z) } else { - emit_water_channel(frag, s0.world_y, s1.world_y, + emit_water_channel(frag, s0, s1, s0.left_bank_x, s0.right_bank_x, s1.left_bank_x, s1.right_bank_x, z) } } From 8f2fa5ba73437aff4076f9ff4a7c8de16d557bd4 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 08:37:59 -0700 Subject: [PATCH 07/17] River Run: cleaner score, dressed banks, machined pickups, real debris Four things from playtesting. SOUND. The arcade character was the effect chain, not the notes: a raw sawtooth lead through jux(rev) + phaser is the chiptune signature. Cleanliness here is filtering the oscillator rather than replacing it -- a low-passed saw keeps the bite without the fizz -- plus a room to sit the voices in a space and chorus for width in place of the hard stereo flip. Variety comes from the pattern strings, now eight cycles long with mixed subdivisions and rests, so a melody takes eight bars to come round rather than four; iter() walks the arp through its own inversions. A low sustained pad under everything gives the other voices a floor. Chain DEPTH is a hard constraint: strudel's evaluator recurses per event, and a first attempt (off() duplication plus two sometimesby plus modulated filters, across five tracks) overflowed its stack on the first bar. The melodic variety is in the patterns, which cost nothing at eval time; the combinators are what have to stay shallow. Net result is lighter than before: 14% avg vs 24.5%. BANKS. They were a cone on a stick, repeated. Six flora variants now share one silhouette vocabulary -- conifer, broadleaf, dead, bush, boulder, reeds -- each welded into one mesh, with four pre-generated instances apiece so neighbours are never the same object. Conifer tiers are jittered and rolled, broadleaf crowns are a lumpy union of blobs rather than a ball, trunks taper and lean. Planting went from ~12 evenly scattered trees per section to ~90 in clumps, drawn from a distance-weighted mix: reeds at the waterline, bushes and boulders on the near bank, the tall silhouettes further back. One draw call per plant either way. PICKUPS. A chrome canister that takes the environment reflection, coloured end caps, a hot core, a per-type badge so what you are collecting reads before the colour does, plus two counter-rotating halo rings and a pool of light on the water beneath. DEBRIS. Explosion chunks were unit cubes, which read as flying dice. A shard is a cube whose eight corners are jittered -- corners, so the hull stays closed -- re-emitted with flat per-face normals. They now draw in the lit opaque pass with the metal reflection instead of as unlit alpha quads, because they are solid torn plating and were reading as paper. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/gameplay.das | 314 ++++++++++++++++++------ examples/games/river_run/main.das | 21 ++ examples/games/river_run/rr_audio.das | 143 +++++++---- examples/games/river_run/rr_globals.das | 10 + examples/games/river_run/rr_models.das | 286 +++++++++++++++++++++ 5 files changed, 654 insertions(+), 120 deletions(-) diff --git a/examples/games/river_run/gameplay.das b/examples/games/river_run/gameplay.das index cfd0cfe9e4..4b4370d91e 100644 --- a/examples/games/river_run/gameplay.das +++ b/examples/games/river_run/gameplay.das @@ -282,6 +282,49 @@ def spawn_island(pos : float3) { } } +// Pick what grows here. Reeds only make sense at the waterline, boulders and +// bushes fill the near bank, and the taller silhouettes go further back -- which +// is also how a real bank stacks up from the water. +def flora_kind_for(shore_dist : float) : int { + let r = random_f() + if (shore_dist < 2.6) { + return (r < 0.62 ? FLORA_REEDS : (r < 0.85 ? FLORA_BUSH : FLORA_ROCK)) + } + if (shore_dist < 7.0) { + if (r < 0.30) { return FLORA_BUSH; } + if (r < 0.46) { return FLORA_ROCK; } + if (r < 0.72) { return FLORA_BROADLEAF; } + if (r < 0.86) { return FLORA_CONIFER; } + return FLORA_DEAD + } + if (r < 0.42) { return FLORA_CONIFER; } + if (r < 0.70) { return FLORA_BROADLEAF; } + if (r < 0.82) { return FLORA_BUSH; } + if (r < 0.92) { return FLORA_ROCK; } + return FLORA_DEAD +} + +def spawn_flora(pos : float3; kind : int; size : float) { + var ground = terrain_height(pos.x, pos.y) + for (dx in fixed_array(-size * 0.4, size * 0.4)) { + ground = max(ground, terrain_height(pos.x + dx, pos.y)) + } + let grounded = float3(pos.x, pos.y, ground) + create_entity() @(eid, cmp) { + apply_decs_template(cmp, RiverTree( + pos = grounded, + size = size, + tiers = 3, + green_tint = random_f(), + green_shift = random_range(-0.12, 0.12), + trunk_ratio = 0.35, + kind = kind, + seed = int(random_f() * 1000.0), + yaw = random_f() * 2.0 * PI + )) + } +} + def spawn_river_tree(pos : float3; size : float; tiers : int; green_tint, green_shift, trunk_ratio : float) { let grounded = float3(pos.x, pos.y, terrain_height(pos.x, pos.y)) create_entity() @(eid, cmp) { @@ -291,7 +334,10 @@ def spawn_river_tree(pos : float3; size : float; tiers : int; green_tint, green_ tiers = tiers, green_tint = green_tint, green_shift = green_shift, - trunk_ratio = trunk_ratio + trunk_ratio = trunk_ratio, + kind = FLORA_CONIFER, + seed = int(random_f() * 1000.0), + yaw = random_f() * 2.0 * PI )) } } @@ -399,24 +445,33 @@ def spawn_section_islands(base_y, section_mult : float) { } // River-side trees — denser and varied. +// Bank dressing. The old pass scattered ~12 conifers over a 240m section, which +// is why the banks read as empty fields with the occasional lollipop. This one +// plants roughly six times as much and draws from the whole flora vocabulary, +// clustered rather than evenly sprinkled -- real banks come in thickets with +// gaps between them, not in a uniform scatter. def spawn_section_trees(base_y : float) { - let tree_attempts = int(18.0 + random_f() * 9.0) - for (_i in range(tree_attempts)) { - if (random_f() > 0.52) { - continue - } - let ty = base_y + random_range(8.0, SECTION_LENGTH - 8.0) - let seg = sample_river_segment(ty) + let clumps = int(26.0 + random_f() * 12.0) + for (_c in range(clumps)) { + let cy = base_y + random_range(6.0, SECTION_LENGTH - 6.0) let left_side = random_f() < 0.5 - let bank_x = (left_side ? seg.left_bank_x : seg.right_bank_x) - let away = (random_f() < 0.32 ? random_range(5.2, 10.0) : random_range(1.4, 5.2)) - let tx = bank_x + (left_side ? -away : away) - let tsize = random_range(0.9, 2.1) - let tiers = (random_f() < 0.48 ? 2 : 3) - let tint = random_range(0.25, 1.0) - let shift = random_range(-0.08, 0.08) - let trunk = random_range(0.35, 0.55) - spawn_river_tree(float3(tx, ty, 0.0), tsize, tiers, tint, shift, trunk) + let members = 1 + int(random_f() * 4.0) + for (_m in range(members)) { + let ty = cy + random_range(-4.5, 4.5) + let seg = sample_river_segment(ty) + let bank_x = (left_side ? seg.left_bank_x : seg.right_bank_x) + // Distance inland, biased toward the waterline where the eye is. + let r = random_f() + let away = (r < 0.30 ? random_range(0.2, 2.6) + : (r < 0.72 ? random_range(2.6, 7.0) : random_range(7.0, 16.0))) + let tx = bank_x + (left_side ? -away : away) + let kind = flora_kind_for(away) + let base_size = (kind == FLORA_REEDS ? random_range(0.8, 1.4) + : (kind == FLORA_BUSH ? random_range(0.7, 1.2) + : (kind == FLORA_ROCK ? random_range(0.35, 0.95) + : random_range(0.95, 2.2)))) + spawn_flora(float3(tx, ty, 0.0), kind, base_size) + } } commit() } @@ -1500,19 +1555,12 @@ def private shadow_cast_craft() { // Bank scenery: trees and houses, which throw the long streaks across the // ground that give the low sun its scale. def private shadow_cast_scenery() { + let per_kind = max(length(geo_flora) / FLORA_VARIANTS, 1) query() $(t : RiverTree) { - let trunk_h = t.size * t.trunk_ratio - draw_shadow_caster(t.pos + float3(0.0, 0.0, trunk_h * 0.5), - float3(0.11 * t.size, 0.11 * t.size, trunk_h)) - geo_cylinder |> draw_geometry_fragment() - for (ti in range(t.tiers)) { - let tf = float(ti) / float(max(1, t.tiers - 1)) - let cone_h = t.size * (1.2 - tf * 0.28) - let cone_r = t.size * (0.72 - tf * 0.18) - let cone_z = trunk_h * 0.72 + tf * (t.size * 0.58) + cone_h * 0.5 - draw_shadow_caster(t.pos + float3(0.0, 0.0, cone_z), float3(cone_r, cone_r, cone_h)) - geo_cone |> draw_geometry_fragment() - } + if (empty(geo_flora)) { return ; } + draw_shadow_caster(t.pos, float3(t.size), quat_z_rot(t.yaw)) + let idx = clamp(t.kind, 0, FLORA_VARIANTS - 1) * per_kind + (t.seed % per_kind) + geo_flora[clamp(idx, 0, length(geo_flora) - 1)] |> draw_geometry_fragment() } query() $(h : RiverHouse) { @@ -1718,33 +1766,50 @@ def render_fuel_depots(game_time : float) { } } -// Pine trees: trunk + stacked cones, tinted by the section's foliage colour. +// Bank flora. One welded mesh per prop, tinted through the model palette, so a +// dressed bank costs one draw call per plant instead of one per cone. +// Takes fields, not the template: a DECS query variable can only be accessed +// by field, never passed whole. +def private flora_palette(kind : int; green_tint, green_shift : float; tint : float3) { + if (kind == FLORA_ROCK) { + let stone = lerp(float3(0.34, 0.33, 0.31), get_bank_color() * 1.4, 0.35) + set_model_palette(stone, stone * 1.12, stone, stone * (0.86 + green_shift)) + return + } + if (kind == FLORA_DEAD) { + let wood = float3(0.26 + green_shift * 0.4, 0.20, 0.14) * tint + set_model_palette(wood, wood * 1.15, wood, wood * 0.8) + return + } + let dark = float3(0.05, 0.20, 0.07) + let light = float3(0.15, 0.42, 0.15) + var green = lerp(dark, light, green_tint) + if (kind == FLORA_REEDS) { + green = lerp(green, float3(0.42, 0.44, 0.20), 0.55) + } elif (kind == FLORA_BUSH) { + green = lerp(green, float3(0.12, 0.30, 0.10), 0.35) + } + green = (green + float3(green_shift * 0.22, green_shift * 0.06, -green_shift * 0.12)) * tint + let bark = float3(0.22 + green_shift * 0.18, 0.15, 0.09) * tint + // Two foliage tones plus bark: the accent tone is what gives a crown depth + // instead of reading as one flat silhouette. + set_model_palette(green, green * 1.28 + float3(0.02, 0.05, 0.01), green, bark) +} + def render_trees() { + if (empty(geo_flora)) { + return + } let tint = env_now.foliage_tint + let per_kind = length(geo_flora) / FLORA_VARIANTS query() $(t : RiverTree) { - let trunk_h = t.size * t.trunk_ratio - let trunk_pos = t.pos + float3(0.0, 0.0, trunk_h * 0.5) - let trunk_col = float3(0.22 + t.green_shift * 0.18, 0.15, 0.09) * tint - draw_prop(trunk_pos, float3(0.11 * t.size, 0.11 * t.size, trunk_h), trunk_col, - float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) - geo_cylinder |> draw_geometry_fragment() - - let tier_den = max(1, t.tiers - 1) - let pine_dark = float3(0.05, 0.22, 0.07) - let pine_light = float3(0.13, 0.42, 0.14) - for (ti in range(t.tiers)) { - let tf = float(ti) / float(tier_den) - let cone_h = t.size * (1.2 - tf * 0.28) - let cone_r = t.size * (0.72 - tf * 0.18) - let cone_z = trunk_h * 0.72 + tf * (t.size * 0.58) + cone_h * 0.5 - let base_green = lerp(pine_dark, pine_light, t.green_tint) - let cone_color = (base_green + float3(t.green_shift * 0.28, - t.green_shift * 0.08 - tf * 0.02, -t.green_shift * 0.14)) * tint - draw_prop(t.pos + float3(0.0, 0.0, cone_z), float3(cone_r, cone_r, cone_h), - cone_color, float4(0.0, 0.0, 0.0, 1.0), MAT_FOLIAGE) - geo_cone |> draw_geometry_fragment() - } + flora_palette(t.kind, t.green_tint, t.green_shift, tint) + let mat = (t.kind == FLORA_ROCK ? MAT_STONE : MAT_FOLIAGE) + draw_prop(t.pos, float3(t.size), float3(1.0), quat_z_rot(t.yaw), mat) + let idx = clamp(t.kind, 0, FLORA_VARIANTS - 1) * per_kind + (t.seed % max(per_kind, 1)) + geo_flora[clamp(idx, 0, length(geo_flora) - 1)] |> draw_geometry_fragment() } + clear_model_palette() } // Houses: body + prism roof + a chimney, so the roofline is not a bare wedge. @@ -1772,23 +1837,113 @@ def render_houses() { } } -// Bonuses: a spinning canister with a bright core, so a pickup is visible over -// dark water without relying on the HUD. +// Bonuses. +// +// A pickup has to look worth flying across the channel for, so it is built in +// layers like the explosions are: a machined metal canister that takes the +// environment reflection, a hot emissive core, and -- in the effects pass -- a +// halo ring and a pool of light on the water under it. Each type also gets its +// own silhouette, so what you are collecting is readable before the colour is. + +def private bonus_pulse(game_time : float; phase : float) : float { + return 0.88 + 0.12 * sin(game_time * 4.0 + phase) +} + +def private bonus_body_rot(game_time : float; phase : float) : float4 { + return quat_mul(quat_z_rot(game_time * BONUS_SPIN_SPEED + phase), quat_y_rot(PI * 0.5)) +} + def render_bonuses(game_time : float) { - let q_tilt = quat_y_rot(PI * 0.5) query() $(b : BonusPickup) { let bob = sin(game_time * BONUS_BOB_SPEED + b.bob_phase) * BONUS_BOB_AMPLITUDE let p = b.pos + float3(0.0, 0.0, 0.72 + bob) let color = bonus_color(b.bonus_type) - let spin = quat_z_rot(game_time * BONUS_SPIN_SPEED + b.bob_phase) - let rot = quat_mul(spin, q_tilt) - draw_prop(p, float3(0.24, 0.24, 0.52), color, rot, MAT_GLOW, REFLECT_METAL) + let rot = bonus_body_rot(game_time, b.bob_phase) + let pulse = bonus_pulse(game_time, b.bob_phase) + + // Machined shell: chrome, so it picks up the sky and the sun rather than + // sitting flat the way the old painted cylinder did. + let shell = float3(0.72, 0.76, 0.80) + draw_prop(p, float3(0.36, 0.36, 0.60) * pulse, shell, rot, MAT_METAL, REFLECT_CHROME) geo_cylinder |> draw_geometry_fragment() - draw_prop(p, float3(0.30), color * 1.4, rot, MAT_GLOW) + + // End caps in the pickup's own colour: the type reads off the ends even + // when the shell is busy reflecting. + for (side in fixed_array(-1.0, 1.0)) { + let cap = p + quat_mul_vec(rot, float3(0.0, 0.0, side * 0.60)) * pulse + draw_prop(cap, float3(0.40, 0.40, 0.09) * pulse, color * 1.4, rot, MAT_GLOW) + geo_cylinder |> draw_geometry_fragment() + } + + // Type badge: a distinct shape bolted to the middle of the shell. + if (b.bonus_type == BonusType.multishot) { + // Three barrels. + for (i in range(3)) { + let a = game_time * BONUS_SPIN_SPEED + b.bob_phase + float(i) * (2.0 * PI / 3.0) + let off = float3(cos(a), sin(a), 0.0) * 0.27 + draw_prop(p + off, float3(0.10, 0.10, 0.42) * pulse, color, rot, MAT_METAL, REFLECT_METAL) + geo_cylinder |> draw_geometry_fragment() + } + } elif (b.bonus_type == BonusType.fastshot) { + // A forward chevron. + draw_prop(p + float3(0.0, 0.40, 0.0), float3(0.27, 0.27, 0.33) * pulse, + color, quat_x_rot(-PI * 0.5), MAT_GLOW) + geo_cone |> draw_geometry_fragment() + } elif (b.bonus_type == BonusType.life) { + // A cross. + draw_prop(p, float3(0.46, 0.13, 0.13) * pulse, color, rot, MAT_GLOW) + geo_cube |> draw_geometry_fragment() + draw_prop(p, float3(0.13, 0.13, 0.46) * pulse, color, rot, MAT_GLOW) + geo_cube |> draw_geometry_fragment() + } else { + // Fuel: a banded collar. + draw_prop(p, float3(0.41, 0.41, 0.16) * pulse, color, rot, MAT_GLOW) + geo_cylinder |> draw_geometry_fragment() + } + + // The core, always hot. + draw_prop(p, float3(0.22) * pulse, color * 2.2, rot, MAT_GLOW) geo_sphere |> draw_geometry_fragment() } } +// Additive layers for the pickups: a halo ring that spins on its own axis, and a +// pool of light where the pickup is reflected on the water. Drawn with the +// effects, after the opaque scene. +def render_bonus_fx(game_time : float) { + // Light pool on the water first -- a soft billboard laid flat. + use_puff_program() + query() $(b : BonusPickup) { + let color = bonus_color(b.bonus_type) + let pulse = bonus_pulse(game_time, b.bob_phase) + draw_puff(float3(b.pos.x, b.pos.y, 0.05), 1.7 * pulse, color, 0.38, 1.8, + game_time * 0.3 + b.bob_phase, b.bob_phase) + geo_disc |> draw_geometry_fragment() + } + + glUseProgram(ring_program) + active_program = ring_program + query() $(b : BonusPickup) { + let bob = sin(game_time * BONUS_BOB_SPEED + b.bob_phase) * BONUS_BOB_AMPLITUDE + let p = b.pos + float3(0.0, 0.0, 0.72 + bob) + let color = bonus_color(b.bonus_type) + let pulse = bonus_pulse(game_time, b.bob_phase) + // Two rings on different axes, counter-rotating: reads as a containment + // field rather than as a decal. + let tilt_a = quat_mul(quat_z_rot(game_time * 1.6 + b.bob_phase), quat_x_rot(PI * 0.42)) + let tilt_b = quat_mul(quat_z_rot(-game_time * 1.1 - b.bob_phase), quat_y_rot(PI * 0.5)) + for (rot, radius in fixed_array(tilt_a, tilt_b), fixed_array(0.92, 0.74)) { + v_model = compose(p, rot, float3(radius * pulse, radius * pulse, 1.0)) + f_Color = float4(color * 1.7, 0.80) + f_Material = float4(0.13, 0.0, 1.9, 0.0) + vs_ring_bind_uniform(active_program) + fs_ring_bind_uniform(active_program) + geo_disc |> draw_geometry_fragment() + } + } + use_unlit_program() +} + // --- Transparent scene --- // Explosion layers draw in three passes so they composite correctly: smoke and @@ -1811,19 +1966,6 @@ def private draw_smoke(game_time : float) { use_unlit_program() } -def private draw_debris() { - query() $(p : Particle) { - if (p.kind != ParticleKind.debris) { return ; } - let life = p.lifetime / p.max_life - let rot = quat_y_rot(p.spin_phase + p.spin_speed * (1.0 - life)) - // Chunks glow briefly then go cold and dark. - let heat = saturate((life - 0.82) * 5.5) - let tint = lerp(p.color * 0.5, float3(1.0, 0.62, 0.24), heat) - draw_unlit(p.pos, float3(p.size), tint, saturate(life * 3.0), rot, heat * 1.6) - geo_cube |> draw_geometry_fragment() - } -} - def private draw_hot_layers() { // Flash and fireballs are soft billboards; only the streaking embers below // want real geometry. @@ -1896,7 +2038,6 @@ def private draw_shockwaves() { def render_projectiles(game_time : float) { use_unlit_program() draw_smoke(game_time) - draw_debris() // Player tracers: a bright core with a longer, dimmer streak behind it. query() $(b : PlayerBullet) { @@ -1932,6 +2073,7 @@ def render_projectiles(game_time : float) { use_unlit_program() draw_hot_layers() draw_shockwaves() + render_bonus_fx(game_time) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) } @@ -1975,10 +2117,36 @@ def render_rotors(game_time : float) { glEnable(GL_CULL_FACE) } +// Explosion debris draws with the scene, not with the effects: it is solid +// torn plating, so it wants the lit prop program, real shading and the metal +// reflection -- as an unlit alpha quad it read as flying paper. +def render_debris() { + if (empty(geo_shards)) { + return + } + use_prop_program() + query() $(p : Particle) { + if (p.kind != ParticleKind.debris) { return ; } + let life = p.lifetime / p.max_life + let tumble = quat_mul(quat_y_rot(p.spin_phase + p.spin_speed * (1.0 - life)), + quat_x_rot(p.spin_phase * 1.7 + p.spin_speed * 0.6 * (1.0 - life))) + // Chunks glow briefly at the flash, then go cold. Shrinking over the + // last of their life hides the pop when they are culled. + let heat = saturate((life - 0.82) * 5.5) + let tint = lerp(p.color * 0.7, float3(1.0, 0.62, 0.24), heat) + let fade = saturate(life * 4.0) + draw_prop(p.pos, float3(p.size * fade), tint, tumble, + float4(0.45, 0.55, heat * 1.4, 0.30), REFLECT_METAL) + let shard_idx = int(p.spin_phase * 1.9) % length(geo_shards) + geo_shards[shard_idx] |> draw_geometry_fragment() + } +} + def render_all(game_time : float) { render_player(game_time) render_enemies(game_time) render_obstacles(game_time) + render_debris() } def render_effects(game_time : float) { diff --git a/examples/games/river_run/main.das b/examples/games/river_run/main.das index 7173279b60..913ed2ddb6 100644 --- a/examples/games/river_run/main.das +++ b/examples/games/river_run/main.das @@ -27,6 +27,27 @@ def create_gl_objects() { geo_heli <- create_geometry_fragment <| gen_helicopter() geo_gunboat <- create_geometry_fragment <| gen_gunboat() geo_jet <- create_geometry_fragment <| gen_jet() + for (frag in geo_shards) { + finalize(frag) + } + geo_shards |> clear() + geo_shards |> reserve(SHARD_VARIANTS) + for (i in range(SHARD_VARIANTS)) { + geo_shards |> emplace(create_geometry_fragment(gen_shard_variant(i))) + } + + // Several pre-generated instances of each flora kind, so a crowded bank is + // not the same tree stamped forty times. + for (frag in geo_flora) { + finalize(frag) + } + geo_flora |> clear() + geo_flora |> reserve(FLORA_VARIANTS * FLORA_INSTANCES) + for (kind in range(FLORA_VARIANTS)) { + for (i in range(FLORA_INSTANCES)) { + geo_flora |> emplace(create_geometry_fragment(gen_flora(kind, kind * 31 + i * 7 + 1))) + } + } cache_ttf_objects() hud_font = cache_font("{get_das_root()}/modules/dasStbImage/fonts/droidsansmono.ttf") diff --git a/examples/games/river_run/rr_audio.das b/examples/games/river_run/rr_audio.das index 62181bb230..64359c251f 100644 --- a/examples/games/river_run/rr_audio.das +++ b/examples/games/river_run/rr_audio.das @@ -259,6 +259,7 @@ def stop_all_tracks(fade : float) { strudel_command("stop:bass:{fade}") strudel_command("stop:lead:{fade}") strudel_command("stop:arp:{fade}") + strudel_command("stop:pad:{fade}") } def start_menu_music() { @@ -282,53 +283,86 @@ def start_win_music() { } // ─── Per-voice rich builders ───────────────────────────────────────── -// Same effect chain across every music mode: -// lead — chunk(4, fast(2)) rotates a stuttered quarter per cycle so -// the 16-note loop never repeats identically; sometimesby -// sprinkles octave jumps; jux(rev) gives stereo motion; -// phaser adds slow phase shimmer; perlin gain breathes. -// bass — sub-octave layer for low end; ply(2) double-taps occasionally. -// arp — jux(rev) + occasional double-time bursts. -// drums — half-bar gain swell. -// Run on the strudel thread (called from music_cmd "rich:..."). BPM is -// set separately by the main-thread caller. +// +// The first pass leaned on a raw sawtooth lead through jux(rev) + phaser, which +// is the classic arcade-chiptune signature: bright, swirly and harsh. Cleaner +// here means filtering the oscillator rather than replacing it -- a low-passed +// saw keeps the bite without the fizz -- plus a room to sit the voices in a +// space instead of hard against the ear, and chorus for width in place of the +// hard stereo flip. +// +// Variety comes from two places. The pattern strings below are eight cycles +// long with mixed subdivisions and rests, so a melody takes eight bars to come +// round rather than four. On top of that each voice carries structural +// variation -- off() for a delayed answering voice, iter() to rotate the arp, +// sometimesby() for ornaments -- so no two passes through the loop land +// identically. def rich_drums(pattern : string; g : float) { replace_named_track("drums", - s(pattern) |> gain(saw() |> range(0.7, 1.0) |> fast(2.0lf)), + s(pattern) + |> gain(saw() |> range(0.74, 1.0) |> fast(2.0lf)) + |> room(0.16), g) } def rich_bass(pattern, osc : string; g : float) { replace_named_track("bass", note_pattern(pattern, osc) - |> superimpose(@(var x : Pattern) => x |> add(-12.0) |> gain(0.6)) - |> sometimesby(0.15, @(var x : Pattern) => x |> ply(2)) - |> attack(0.005) - |> release(0.18), + |> superimpose(@(var x : Pattern) => x |> add(-12.0) |> gain(0.55)) + // A fixed low pass is what turns a square bass from buzzy into round. + // Modulating it costs an extra signal pattern per event for no audible + // gain down here. + |> lpf(680.0) + |> attack(0.006) + |> release(0.20) + |> room(0.10), g) } def rich_lead(pattern, osc : string; g : float) { replace_named_track("lead", note_pattern(pattern, osc) - |> chunk(4, @(var x : Pattern) => x |> fast(2.0lf)) - |> sometimesby(0.18, @(var x : Pattern) => x |> add(12.0)) - |> jux(@(var x : Pattern) => rev(x)) - |> phaser(0.25) - |> attack(0.006) - |> release(0.16) - |> gain(perlin() |> range(0.75, 1.0) |> slow(3.0lf)), + // Cleanliness is the filter, not the oscillator: a low-passed saw keeps + // the bite without the fizz that made the first pass read as arcade. + // Depth here is deliberately shallow -- the strudel evaluator recurses + // per event, and a deeper chain (off + two sometimesby + modulated + // filters, across five tracks) overflows its stack. + |> sometimesby(0.14, @(var x : Pattern) => x |> add(12.0)) + |> lpf(2600.0) + |> chorus(0.30) + |> attack(0.010) + |> release(0.26) + |> room(0.26) + |> gain(perlin() |> range(0.80, 1.0) |> slow(3.0lf)), g) } def rich_arp(pattern : string; g : float) { replace_named_track("arp", note_pattern(pattern, "triangle") - |> jux(@(var x : Pattern) => rev(x)) - |> sometimesby(0.20, @(var x : Pattern) => x |> fast(2.0lf)) - |> attack(0.005) - |> release(0.12), + // iter rotates the figure by one step each cycle, so the arp walks + // through its own inversions instead of repeating. It is the cheapest + // variety in the mix: pure re-indexing, no extra events. + |> iter(4) + |> lpf(3200.0) + |> attack(0.006) + |> release(0.22) + |> room(0.34), + g) +} + +// A sustained chord bed under everything, mixed low on purpose -- it carries no +// melody and exists to give the other voices a floor to stand on, which is most +// of what separated the first pass from sounding like a soundtrack. +def rich_pad(pattern : string; g : float) { + replace_named_track("pad", + note_pattern(pattern, "triangle") + |> lpf(900.0) + |> attack(0.35) + |> release(0.9) + |> sustain(0.8) + |> room(0.55), g) } @@ -338,41 +372,52 @@ def fade_arp(fade : float) { } } +def fade_pad(fade : float) { + if (key_exists(g_music_tracks, "pad")) { + strudel_fade_track(g_music_tracks["pad"], 0.0, fade) + } +} + // ─── Section builders (gameplay loop, square+saw+triangle stack) ───── def build_section0_rich() { - rich_drums(" ", 0.48) - rich_bass("<[e2 e2] ~ [b1 b1] ~> <[g2 g2] ~ [d2 d2] ~> <[c2 c2] ~ [g1 g1] ~> <[a1 a1] [b1 b1] [c2 c2] [d2 d2]>", "square", 0.30) - rich_lead(" ", "sawtooth", 0.20) - rich_arp("<[e5 b4 g4 e4]> <[d5 a4 f4 d4]> <[c5 g4 e4 c4]> <[b4 fs4 d4 b3]>", 0.10) + rich_drums(" ", 0.42) + rich_bass(" ", "square", 0.28) + rich_lead("<[e4 ~] g4 [b4 a4] ~> <[g4 b4] e5 ~ d5> <[e4 fs4] g4 [b4 d5] ~> <[a4 b4] ~ e4 ~> <[e4 g4] [b4 g4] e4 ~>", "sawtooth", 0.19) + rich_arp("<[e5 b4 g4 e4]> <[d5 a4 f4 d4]> <[c5 g4 e4 c4]> <[b4 fs4 d4 b3]>", 0.09) + rich_pad(" ", 0.13) } def build_section1_rich() { - rich_drums(" ", 0.52) - rich_bass("<[a1 a1] ~ [e2 e2] ~> <[c2 c2] ~ [g1 g1] ~> <[d2 d2] ~ [a1 a1] ~> <[f2 f2] [e2 e2] [d2 d2] [c2 c2]>", "square", 0.32) - rich_lead(" ", "sawtooth", 0.22) - rich_arp("<[a5 e5 c5 a4]> <[g5 d5 b4 g4]> <[f5 c5 a4 f4]> <[e5 b4 g4 e4]>", 0.10) + rich_drums(" ", 0.46) + rich_bass(" ", "square", 0.30) + rich_lead(" <[g4 b4] d5 ~ [b4 g4]> <[e4 g4] b4 [e5 ~] d5> <[a4 ~] c5 [e5 d5] ~> <[f4 a4] ~ c5 [a4 f4]> ", "sawtooth", 0.21) + rich_arp("<[a5 e5 c5 a4]> <[g5 d5 b4 g4]> <[f5 c5 a4 f4]> <[e5 b4 g4 e4]>", 0.09) + rich_pad(" ", 0.14) } def build_section2_rich() { - rich_drums("<[bd bd] [hh hh] [sd sd] [hh hh]> <[bd bd] [hh cp] [sd sd] [cp hh]> <[bd bd] hh sd [hh hh]>", 0.56) - rich_bass("<[d2 d2] ~ [a1 a1] ~> <[f2 f2] ~ [c2 c2] ~> <[g2 g2] ~ [d2 d2] ~> <[a2 a2] [g2 g2] [f2 f2] [e2 e2]>", "square", 0.34) - rich_lead(" ", "sawtooth", 0.24) - rich_arp("<[d5 a4 f4 d4]> <[c5 g4 e4 c4]> <[bf4 f4 d4 bf3]> <[a4 e4 c4 a3]>", 0.11) + rich_drums("<[bd bd] [hh hh] [sd sd] [hh hh]> <[bd bd] [hh cp] [sd sd] [cp hh]> <[bd bd] hh sd [hh hh]>", 0.50) + rich_bass(" ", "square", 0.32) + rich_lead("<[d4 f4] a4 ~ [d5 a4]> <[bf3 d4] f4 [bf4 ~] a4> <[a3 c4] e4 ~ a4> <[c4 e4] g4 [c5 ~] e5> <[a3 e4] [a4 c5] ~ ~>", "sawtooth", 0.23) + rich_arp("<[d5 a4 f4 d4]> <[c5 g4 e4 c4]> <[bf4 f4 d4 bf3]> <[a4 e4 c4 a3]>", 0.10) + rich_pad(" ", 0.15) } def build_section3_rich() { - rich_drums("<[bd bd] [hh hh] [sd cp] [hh hh]> <[bd bd] [hh hh] [sd sd] [cp cp]> <[bd bd] [hh hh] [sd cp] [hh hh]>", 0.60) - rich_bass("<[b1 b1] ~ [fs2 fs2] ~> <[d2 d2] ~ [a1 a1] ~> <[e2 e2] ~ [b1 b1] ~> <[g2 g2] [fs2 fs2] [e2 e2] [d2 d2]>", "square", 0.36) - rich_lead(" ", "sawtooth", 0.26) - rich_arp("<[b5 fs5 d5 b4]> <[a5 e5 c5 a4]> <[g5 d5 b4 g4]> <[fs5 c5 a4 fs4]>", 0.13) + rich_drums("<[bd bd] [hh hh] [sd cp] [hh hh]> <[bd bd] [hh hh] [sd sd] [cp cp]> <[bd bd] [hh hh] [sd cp] [hh hh]>", 0.54) + rich_bass(" ", "square", 0.34) + rich_lead("<[b4 d5] fs5 ~ b5> <[a4 c5] e5 ~ [c5 a4]> <[fs4 a4] c5 [fs5 ~] e5> <[b4 ~] d5 [fs5 e5] ~> <[g4 b4] ~ d5 [b4 g4]> ", "sawtooth", 0.25) + rich_arp("<[b5 fs5 d5 b4]> <[a5 e5 c5 a4]> <[g5 d5 b4 g4]> <[fs5 c5 a4 fs4]>", 0.12) + rich_pad(" ", 0.16) } def build_section4_rich() { - rich_drums("<[bd bd] [hh hh] [sd cp] [hh cp]> <[bd cp] [hh hh] [sd sd] [hh cp]> <[bd bd] [hh cp] [sd cp] [cp hh]> <[bd bd] [hh hh] [sd cp] [hh hh]>", 0.64) - rich_bass("<[g1 g1] ~ [d2 d2] ~> <[bf1 bf1] ~ [f2 f2] ~> <[c2 c2] ~ [g1 g1] ~> <[d2 d2] [c2 c2] [bf1 bf1] [a1 a1]>", "square", 0.38) - rich_lead(" ", "sawtooth", 0.28) - rich_arp("<[g5 d5 bf4 g4]> <[f5 c5 a4 f4]> <[ef5 bf4 g4 ef4]> <[d5 a4 f4 d4]>", 0.14) + rich_drums("<[bd bd] [hh hh] [sd cp] [hh cp]> <[bd cp] [hh hh] [sd sd] [hh cp]> <[bd bd] [hh cp] [sd cp] [cp hh]> <[bd bd] [hh hh] [sd cp] [hh hh]>", 0.58) + rich_bass(" ", "square", 0.36) + rich_lead("<[g4 bf4] d5 ~ [g5 d5]> <[ef4 g4] bf4 [ef5 ~] d5> <[d4 f4] a4 ~ d5> <[f4 a4] c5 [f5 ~] a5> <[d4 a4] [d5 f5] ~ ~>", "sawtooth", 0.27) + rich_arp("<[g5 d5 bf4 g4]> <[f5 c5 a4 f4]> <[ef5 bf4 g4 ef4]> <[d5 a4 f4 d4]>", 0.13) + rich_pad(" ", 0.17) } // ─── Transition builders (menu / tension / game-over / win) ────────── @@ -383,7 +428,8 @@ def build_section4_rich() { def build_menu_rich() { rich_drums(" <~ ~ hh ~>", 0.18) rich_bass(" ", "sine", 0.20) - rich_lead(" ", "triangle", 0.10) + rich_lead(" ", "triangle", 0.10) + rich_pad(" ", 0.16) fade_arp(0.4) } @@ -391,6 +437,7 @@ def build_tension_rich() { rich_drums(" ", 0.22) rich_bass(" ", "sine", 0.26) rich_lead(" ", "triangle", 0.14) + rich_pad(" ", 0.18) fade_arp(0.4) } @@ -398,6 +445,7 @@ def build_game_over_rich() { rich_drums(" ", 0.18) rich_bass(" ", "sine", 0.26) rich_lead(" ", "triangle", 0.16) + rich_pad(" ", 0.20) fade_arp(0.3) } @@ -406,6 +454,7 @@ def build_win_rich() { rich_bass("<[e2 e2] [g2 g2] [b2 b2] [e3 e3]> <[d3 d3] [b2 b2] [a2 a2] [g2 g2]> <[c3 c3] [a2 a2] [g2 g2] [b2 b2]> <[e2 e2] [g2 g2] [b2 b2] [e3 e3]>", "square", 0.28) rich_lead(" ", "sawtooth", 0.22) rich_arp("<[e6 b5 g5 e5]> <[g6 d6 b5 g5]> <[a6 e6 c6 a5]> <[b6 fs6 d6 b5]>", 0.12) + rich_pad(" ", 0.16) } // 5 distinct gameplay music variants (cycles through sections 0..4). diff --git a/examples/games/river_run/rr_globals.das b/examples/games/river_run/rr_globals.das index 8a4fce734c..cb195217c9 100644 --- a/examples/games/river_run/rr_globals.das +++ b/examples/games/river_run/rr_globals.das @@ -217,6 +217,11 @@ struct Island { size : float } +// One template covers everything growing on the bank -- conifers, broadleaf, +// dead trees, bushes, boulders and reeds -- because they differ only in which +// mesh they draw and how they are tinted. `kind` selects the mesh; `seed` +// selects which pre-generated instance of that mesh, so no two neighbours are +// the same object. [decs_template] struct RiverTree { pos : float3 @@ -225,6 +230,9 @@ struct RiverTree { green_tint : float green_shift : float trunk_ratio : float + kind : int + seed : int + yaw : float } [decs_template] @@ -569,6 +577,8 @@ var geo_heli : OpenGLGeometryFragment var geo_heli_tail : OpenGLGeometryFragment var geo_gunboat : OpenGLGeometryFragment var geo_jet : OpenGLGeometryFragment +var geo_shards : array +var geo_flora : array var hud_font : Font? var river_bank_geo : OpenGLGeometryFragment var river_surface_geo : OpenGLGeometryFragment diff --git a/examples/games/river_run/rr_models.das b/examples/games/river_run/rr_models.das index eb66947a3d..1830bd3b3a 100644 --- a/examples/games/river_run/rr_models.das +++ b/examples/games/river_run/rr_models.das @@ -259,3 +259,289 @@ def set_model_palette(body, accent, metal, dark : float3) { def clear_model_palette() { f_PaletteOn = 0.0 } + +// ============================================================================ +// Debris shards +// ============================================================================ +// +// Explosion chunks were unit cubes, which read as flying dice. A shard is a cube +// whose eight CORNERS are jittered -- corners, not vertices, so shared corners +// move together and the hull stays closed -- then re-emitted with flat per-face +// normals. The faceting is the point: an angular chunk catching the sun on one +// face reads as torn plating, where a smooth-shaded one reads as a pebble. + +let SHARD_VARIANTS = 6 + +def private shard_hash(i : int; salt : float) : float { + let h = sin(float(i) * 12.9898 + salt * 78.233) * 43758.545 + return h - floor(h) +} + +def private gen_shard(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + // Eight jittered corners, plus a per-variant squash so the family spans + // plates, splinters and blocks rather than eight near-identical dice. + let squash = float3( + 0.55 + shard_hash(seed, 1.0) * 0.85, + 0.55 + shard_hash(seed, 2.0) * 0.85, + 0.35 + shard_hash(seed, 3.0) * 0.6 + ) + var corner : float3[8] + for (i in range(8)) { + let sx = ((i & 1) != 0 ? 1.0 : -1.0) + let sy = ((i & 2) != 0 ? 1.0 : -1.0) + let sz = ((i & 4) != 0 ? 1.0 : -1.0) + let jitter = float3( + shard_hash(seed * 8 + i, 11.0) - 0.5, + shard_hash(seed * 8 + i, 23.0) - 0.5, + shard_hash(seed * 8 + i, 37.0) - 0.5 + ) * 0.7 + corner[i] = (float3(sx, sy, sz) + jitter) * squash + } + + // Cube faces as corner-index quads, wound counter-clockwise from outside. + let faces = fixed_array( + int4(1, 3, 7, 5), // +x + int4(2, 0, 4, 6), // -x + int4(3, 2, 6, 7), // +y + int4(0, 1, 5, 4), // -y + int4(4, 5, 7, 6), // +z + int4(2, 3, 1, 0) // -z + ) + frag.vertices |> reserve(6 * 6) + frag.indices |> reserve(6 * 6) + for (f in faces) { + let a = corner[f.x] + let b = corner[f.y] + let c = corner[f.z] + let d = corner[f.w] + // One flat normal per triangle, so every facet catches the light on its + // own terms. + for (tri in fixed_array(int3(0, 1, 2), int3(0, 2, 3))) { + let p = fixed_array(a, b, c, d) + let v0 = p[tri.x] + let v1 = p[tri.y] + let v2 = p[tri.z] + let n = normalize(cross(v1 - v0, v2 - v0)) + let base = length(frag.vertices) + for (v in fixed_array(v0, v1, v2)) { + frag.vertices |> push(GeometryPreviewVertex(xyz = v, normal = n, uv = float2(0.0, 0.35))) + } + frag.indices |> push_from([base, base + 1, base + 2]) + } + } + gen_bbox(frag) + return <- frag +} + +def gen_shard_variant(i : int) : GeometryFragment { + return <- gen_shard(i % SHARD_VARIANTS) +} + +// ============================================================================ +// Bank flora +// ============================================================================ +// +// The banks were a cone on a stick, repeated. Two things made that read as +// cartoon: every tree was the same perfect cone, and a tree was the only thing +// growing. These are six variants of a shared silhouette vocabulary -- conifer, +// broadleaf, dead, bush, boulder, reeds -- each welded into ONE mesh so a +// crowded bank is still one draw call per prop. +// +// Foliage rides PART_BODY and PART_ACCENT, wood rides PART_DARK. PART_METAL is +// deliberately unused: the prop shader auto-reflects that slot, and a chrome +// tree trunk is not the goal. + +let FLORA_CONIFER = 0 +let FLORA_BROADLEAF = 1 +let FLORA_DEAD = 2 +let FLORA_BUSH = 3 +let FLORA_ROCK = 4 +let FLORA_REEDS = 5 +let FLORA_VARIANTS = 6 +// Distinct pre-generated meshes per kind. Four is enough that a bank does not +// read as a repeat, and cheap enough to build every one at load. +let FLORA_INSTANCES = 4 + +def private flora_hash(i : int; salt : float) : float { + let h = sin(float(i) * 45.164 + salt * 91.377) * 43758.545 + return h - floor(h) +} + +// A trunk that actually tapers, leans a little and is not perfectly round. +def private weld_trunk(var frag : GeometryFragment; src : GeometryFragment; + height, radius, lean : float; seed : int) { + let segs = 3 + for (i in range(segs)) { + let t0 = float(i) / float(segs) + let t1 = float(i + 1) / float(segs) + let r0 = radius * (1.0 - t0 * 0.55) + let mid = (t0 + t1) * 0.5 + let off = float3(lean * mid * mid, lean * 0.4 * mid * mid, 0.0) + weld(frag, src, off + float3(0.0, 0.0, height * (t0 + t1) * 0.5), + float3(r0, r0 * (0.85 + flora_hash(seed * 4 + i, 3.0) * 0.3), height * (t1 - t0) * 0.5), + no_rot(), PART_DARK, GLOSS_MATTE) + } +} + +def private gen_conifer(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var cone <- gen_cone(GenDirection.xy, 9) + var tube <- gen_cylinder(GenDirection.xy, 7) + + let lean = (flora_hash(seed, 1.0) - 0.5) * 0.22 + weld_trunk(frag, tube, 0.55, 0.075, lean, seed) + + // Four to six tiers, each nudged off-axis and rolled, so no two conifers + // present the same silhouette. + let tiers = 4 + int(flora_hash(seed, 2.0) * 2.99) + for (i in range(tiers)) { + let t = float(i) / float(max(tiers - 1, 1)) + let h = 0.9 - t * 0.34 + let r = 0.62 - t * 0.30 + let jitter = float3( + (flora_hash(seed * 7 + i, 4.0) - 0.5) * 0.12, + (flora_hash(seed * 7 + i, 5.0) - 0.5) * 0.12, + 0.0 + ) + let z = 0.34 + t * 1.05 + h * 0.5 + let part = (i % 2 == 0 ? PART_BODY : PART_ACCENT) + weld(frag, cone, jitter + float3(lean * 0.8, lean * 0.3, z), + float3(r, r * (0.88 + flora_hash(seed * 7 + i, 6.0) * 0.24), h * 0.5), + axis_rot(float3(0.0, 0.0, 1.0), flora_hash(seed * 7 + i, 7.0) * 6.28), + part, GLOSS_MATTE) + } + delete cone + delete tube + gen_bbox(frag) + return <- frag +} + +def private gen_broadleaf(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var blob <- gen_sphere(10, 7, false) + var tube <- gen_cylinder(GenDirection.xy, 7) + + let lean = (flora_hash(seed, 1.0) - 0.5) * 0.3 + weld_trunk(frag, tube, 0.85, 0.085, lean, seed) + + // Crown built from overlapping squashed blobs rather than one ball: the + // lumpy union is what stops it reading as a lollipop. + let blobs = 4 + int(flora_hash(seed, 2.0) * 2.99) + for (i in range(blobs)) { + let a = flora_hash(seed * 11 + i, 3.0) * 6.28 + let rad = 0.16 + flora_hash(seed * 11 + i, 4.0) * 0.26 + let up = 1.02 + flora_hash(seed * 11 + i, 5.0) * 0.42 + let size = 0.32 + flora_hash(seed * 11 + i, 6.0) * 0.24 + let part = (i % 3 == 0 ? PART_ACCENT : PART_BODY) + weld(frag, blob, + float3(cos(a) * rad + lean, sin(a) * rad + lean * 0.4, up), + float3(size, size * (0.85 + flora_hash(seed * 11 + i, 7.0) * 0.3), size * 0.82), + no_rot(), part, GLOSS_MATTE) + } + delete blob + delete tube + gen_bbox(frag) + return <- frag +} + +def private gen_deadtree(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var tube <- gen_cylinder(GenDirection.xy, 6) + + let lean = (flora_hash(seed, 1.0) - 0.5) * 0.4 + weld_trunk(frag, tube, 1.25, 0.075, lean, seed) + + // Bare branches angled off the trunk. A few dead trees among the green ones + // do more for "this bank is a place" than another dozen conifers. + let branches = 3 + int(flora_hash(seed, 2.0) * 2.99) + for (i in range(branches)) { + let a = flora_hash(seed * 13 + i, 3.0) * 6.28 + let up = 0.55 + flora_hash(seed * 13 + i, 4.0) * 0.7 + let len = 0.22 + flora_hash(seed * 13 + i, 5.0) * 0.3 + let tilt = 0.5 + flora_hash(seed * 13 + i, 6.0) * 0.5 + let dir = float3(cos(a), sin(a), 0.0) + weld(frag, tube, dir * len * 0.6 + float3(lean, lean * 0.4, up), + float3(0.03, 0.03, len), + axis_rot(normalize(float3(-dir.y, dir.x, 0.0)), tilt), + PART_DARK, GLOSS_MATTE) + } + delete tube + gen_bbox(frag) + return <- frag +} + +def private gen_bush(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var blob <- gen_sphere(9, 6, false) + let blobs = 3 + int(flora_hash(seed, 1.0) * 2.99) + for (i in range(blobs)) { + let a = flora_hash(seed * 17 + i, 2.0) * 6.28 + let rad = flora_hash(seed * 17 + i, 3.0) * 0.3 + let size = 0.26 + flora_hash(seed * 17 + i, 4.0) * 0.2 + let part = (i % 2 == 0 ? PART_BODY : PART_ACCENT) + weld(frag, blob, float3(cos(a) * rad, sin(a) * rad, size * 0.72), + float3(size, size * 0.9, size * 0.66), no_rot(), part, GLOSS_MATTE) + } + delete blob + gen_bbox(frag) + return <- frag +} + +def private gen_boulder(seed : int) : GeometryFragment { + // Boulders reuse the debris shard: an irregular flat-shaded hull is exactly + // what a rock wants, and it costs nothing extra. + var frag <- gen_shard(seed + 3) + for (v in frag.vertices) { + v.xyz = float3(v.xyz.x, v.xyz.y, v.xyz.z * 0.6 + 0.55) + v.uv = float2(PART_DARK, GLOSS_MATTE) + } + gen_bbox(frag) + return <- frag +} + +def private gen_reeds(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var cone <- gen_cone(GenDirection.xy, 5) + // Stalks fanning outward from a clump: they go right at the waterline, + // which is the one place the old banks had nothing at all. + let stalks = 7 + int(flora_hash(seed, 1.0) * 6.99) + for (i in range(stalks)) { + let a = flora_hash(seed * 19 + i, 2.0) * 6.28 + let rad = flora_hash(seed * 19 + i, 3.0) * 0.26 + let h = 0.42 + flora_hash(seed * 19 + i, 4.0) * 0.5 + let tilt = (flora_hash(seed * 19 + i, 5.0) - 0.5) * 0.5 + let dir = float3(cos(a), sin(a), 0.0) + let part = (i % 3 == 0 ? PART_ACCENT : PART_BODY) + weld(frag, cone, dir * rad + float3(0.0, 0.0, h * 0.5), + float3(0.035, 0.035, h * 0.5), + axis_rot(normalize(float3(-dir.y, dir.x, 0.0)), tilt), + part, GLOSS_MATTE) + } + delete cone + gen_bbox(frag) + return <- frag +} + +// `variant` is a reserved word in gen2, hence `kind`. +def gen_flora(kind, seed : int) : GeometryFragment { + if (kind == FLORA_CONIFER) { + return <- gen_conifer(seed) + } elif (kind == FLORA_BROADLEAF) { + return <- gen_broadleaf(seed) + } elif (kind == FLORA_DEAD) { + return <- gen_deadtree(seed) + } elif (kind == FLORA_BUSH) { + return <- gen_bush(seed) + } elif (kind == FLORA_ROCK) { + return <- gen_boulder(seed) + } + return <- gen_reeds(seed) +} From e5ecfe38c4490c3fedae850a3db3b45c058b431c Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 08:53:29 -0700 Subject: [PATCH 08/17] River Run: ship it on the examples page The card is wasm64-only, like the three showcase cards: the render path (deferred-lit pass plus the SSAO / bloom / sun-shaft chain) needs the compiled build to hold frame rate, so there is no interpreted fallback and no .das sources to stage. pages.yml builds it in the release-wasm loop, verifies the artifact, and stages it with the same "being rebuilt" placeholder rule the other always-listed cards use. Two source changes let ONE tree serve desktop and wasm, matching what arcanoid and boulder-dash already do: - live_api becomes `require ?dashv`. It needs dashv/libhv, which the wasm build neither has nor wants; `?` skips it there and keeps the REST API on desktop. - Music gates itself on audio_is_single_threaded(). The strudel player runs on its own worker, and the wasm audio backend is single-threaded, so there is no worker to host it -- the generated SFX carry the soundtrack there. Native is unaffected: it still reports a threaded backend and still plays music. Also drops a stray libhv log that had been committed in the game directory. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- .github/workflows/pages.yml | 20 +++++++++++++++++++- examples/games/river_run/.das_package | 6 ++++-- examples/games/river_run/rr_audio.das | 14 ++++++++++++-- examples/games/river_run/rr_globals.das | 4 +++- site/examples.html | 2 +- site/files/examples.js | 16 ++++++++++++++++ site/files/examples/river_run-poster.jpg | Bin 0 -> 104513 bytes 7 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 site/files/examples/river_run-poster.jpg diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 43998521d3..1270bb53f5 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -268,7 +268,7 @@ jobs: # Non-fatal per game: a game's wasm build must never red the whole deploy # (which would take the playground + the other example cards down with it). # The staging step degrades a missing game wasm to its interpreted fallback. - for g in arcanoid pacman boulder-dash; do + for g in arcanoid pacman boulder-dash river_run; do if ./bin/daslang utils/daspkg/main.das -- \ release wasm --root "examples/games/$g" --out "$REPO/web/output64/examples"; then echo "$g wasm build OK" @@ -355,6 +355,7 @@ jobs: web/output64/examples/arcanoid/arcanoid.wasm \ web/output64/examples/pacman/pacman.wasm \ web/output64/examples/boulder-dash/boulder-dash.wasm \ + web/output64/examples/river_run/river_run.wasm \ web/output64/examples/furier/furier.wasm \ web/output64/examples/path_tracer_lab/path_tracer_lab.wasm \ web/output64/examples/physarum_lab/physarum_lab.wasm; do @@ -503,6 +504,23 @@ jobs: cp modules/dasStbImage/fonts/droidsansmono.ttf "_site/examples/$g/" done + # river_run — the shadow-mapped river shooter. wasm64-only: its render path + # (deferred-lit pass + SSAO/bloom/sun-shaft chain) needs the compiled build + # to hold frame rate, so there is no interpreted fallback and no .das + # sources to stage. Same placeholder rule as the showcases below: the card + # is always listed, so an incomplete build must not 404 the iframe. + mkdir -p _site/examples/river_run + if [ -f web/output64/examples/river_run/river_run.html ] \ + && [ -f web/output64/examples/river_run/river_run.js ] \ + && [ -f web/output64/examples/river_run/river_run.wasm ]; then + cp web/output64/examples/river_run/river_run.html _site/examples/river_run/ + cp web/output64/examples/river_run/river_run.js _site/examples/river_run/ + cp web/output64/examples/river_run/river_run.wasm _site/examples/river_run/ + else + echo "WARNING: river_run wasm build incomplete — staging a placeholder so the always-listed card doesn't 404." + printf '%s' 'River Run — building

This example is being rebuilt and will be available shortly.

' > _site/examples/river_run/river_run.html + fi + # furier — the ImGui-in-wasm showcase. wasm64-only: it bundles the compiled # dasImgui module, which the universal interpreter can't bind, so there is NO # interpreted fallback (no .das sources to stage). The card is always listed diff --git a/examples/games/river_run/.das_package b/examples/games/river_run/.das_package index 83ebeaf6fe..6a327bc382 100644 --- a/examples/games/river_run/.das_package +++ b/examples/games/river_run/.das_package @@ -5,10 +5,12 @@ require daslib/daspkg [export] def package() { package_name("river-run") - package_description("River Run example with audio + HUD; ships on dasGlfw + dasOpenGL + dasAudio") + package_description("River Run: shadow-mapped river shooter on dasGlfw + dasOpenGL + dasAudio") } [export] def release() { - release_main("main.das") + release_main("main.das") // one source for desktop live-reload AND wasm64: + // live_api is `require ?dashv` (skipped on wasm) and + // music gates itself off on a single-threaded backend } diff --git a/examples/games/river_run/rr_audio.das b/examples/games/river_run/rr_audio.das index 64359c251f..dfe3269723 100644 --- a/examples/games/river_run/rr_audio.das +++ b/examples/games/river_run/rr_audio.das @@ -31,6 +31,12 @@ var audio_initialized = false var asch : AudioSystemChannels var music_initialized = false +// The strudel player runs on its own worker. Under a single-threaded audio +// backend -- which is what the wasm build gets -- there is no worker to host it, +// so music is gated off there and the generated SFX carry the whole soundtrack. +// Set from audio_is_single_threaded() at init, so ONE source serves native and +// wasm alike. +var music_enabled = false var g_music_tracks : table var prev_music_state = GameState.menu var low_fuel_music_active = false @@ -486,7 +492,7 @@ def strudel_music_main() { } def update_music_state() { - if (!music_initialized) { + if (!music_enabled || !music_initialized) { return } let low_fuel = (game_state == GameState.playing && player_fuel < LOW_FUEL_THRESHOLD) @@ -532,7 +538,11 @@ def init_audio() { set_volume(engine_sid, 0.0) set_pause(engine_sid, true) } - if (!music_initialized) { + if (!audio_initialized) { + return + } + music_enabled = !audio_is_single_threaded() + if (music_enabled && !music_initialized) { strudel_init(@@strudel_music_main) strudel_set_volume(0.35, 0.0) music_initialized = true diff --git a/examples/games/river_run/rr_globals.das b/examples/games/river_run/rr_globals.das index cb195217c9..1ddbf50886 100644 --- a/examples/games/river_run/rr_globals.das +++ b/examples/games/river_run/rr_globals.das @@ -11,7 +11,9 @@ require rr_shaders public require rr_models public require rr_postfx public require live/live_commands public -require live/live_api public +// Optional: the REST API needs dashv/libhv, which the wasm build has no use for +// and cannot link. `?` skips it there and keeps it on the desktop. +require ?dashv live/live_api public require live/live_watch_boost public require live/live_vars public // Without decs_live every reload emptied the world -- the player kept flying diff --git a/site/examples.html b/site/examples.html index fda08ea6bf..f7da66be53 100644 --- a/site/examples.html +++ b/site/examples.html @@ -5,7 +5,7 @@ examples — Daslang, compiled to WebAssembly - + h> ztq>n3fDaw2{j@ug z?BsG*7aFWAt8CjxTKAs;c#q;_S4f&P(idz11xKKKHcjefl~1<@zPk8R;IpS{*4J_W z00RSx=EUNlqV&178ZvDkLi{KFoiPcafX+$;Km@mIpH37dwU zt@eAT6~@QlZx2}`OJ~7OaNto}imOzQDvFEp6+Eh(UGH=7PsTs+PE9E3t$Sj-3aLDb z{Ji+7@E=e3eX2>P>NgQ62L~Ybuj{wRemBxQA*8&MSpBLze+bQfVEjh-Gw@sXq17HM z$RyJ)T;qCzwdiFz{dmgt+vM(Z<1*TFkM7duKbxIb;O?2L&;I}tTbQO|4g&iAHRM-c z3U%vS=e0{_ET^!q>AO$=0D?StCrps*ns16G`B!jK2Wt2)#a{+3{vUin)O2YsmUV_q z25&)MVTR&rH5-i?-rd!Y5?Mt^>rq?genvOL&l6im^4ho~+Mv^XGp@sM67JowPEJAp z01EwhyztJ9@slS9&hX|)Zs_+u76V2Y$S!FVz?s}v@w-=uC_6j9v1UHLe#XTwPMZ( zHGpkpE51Av*1a>w65DTYv-|QZ#qRZ%wv%it`d7P$lajgSRgImGYWR1oK?b25TC7>a z3>76!ey4mg_?NB=dw6_17X5$?vH(vWhc)<-YvSvOCi5daucdt*@T=nXrycUgEXQh` z?N-{y>0W+im%6dNi=kOTo|F4-{>!69j&F}&4|J2KY16nmwTgY%C3D2_4uTm8!!PhR z`y^xYe$q{+lY#kH^d<0z;`W{5ZQ3@mX>}wnjTDJ^yWy1>ZDn9`3XpI?>MQc=_Q?I2 zB={lXb@6wC5$EuZu^~3>0Y7DsoSSJ6JqIx{`^)YPetlkqu=PEhmYqM#{J!sBaxzt8 z&bRCSf98C%WpR=Y;yvr${2Q#L{g}0O$&Z@(y4q$%4V-&dZ{eL}SzaR(!RyT_u4vfK zI!fo%&>xg2IQOYa6UaPzn&<4bD6U{uQOV}I5V5pFJR0STHcTH&dkQGJL-=enRoCGNaHn}@iHgj zTeC#6HVT~o0G_o@?L=dYs~qe_@$d}(mQ95)~b@s#hh?|I<{F}K1c-i`qLqewijmU>r^X~z$1T@4r+X0 z;kq6wtYu^Qy5^@W`v;fBNX0R#k+9=Esp}+RaNSAGN4&S+7$%lTm9WXqdi^K@Obp~6 zl`1=u199&~vm!6=268zyWVBLpoPUh~M>g3;4>;*nQXpGvFCNuv$tZ-WH5&Zfq3h{E z418udj{`Uzs`NQ^!({t?Dw;#U$>+UV3p(WI9ViQoY#}V8KGh0*{{VNBR$kz3!vHG0 zZc`;jb4V^jEWUJw0or{kXty94rkIqtk%zw(BQk~QieTAadzT<{=~q@JQM`|Q)H1LH z0nRz5`K=NRpURN#$doa1z;)uMon#=Yuhy2-lElA3?OIrOaucmmMIxQDWMzRI_p7ZN zF&jrpw;CAKvksj<8ov?=K5e-NoK~@ktX8Fa8+l0?BnsSy-Z1+c^QkSPZMk(Ecdkps zJ}uGwIB|0*l3u$Lu73*Yr%B1#BWSB`cZmsy?wz3WNvu6v#u^@mjAfZ!)O1r{J@|v- zR=2Kyquc)CAMUSQR|JyC@?u6TPjYM6p_g@;e$v|>&!~Juz13ye2g;=51Fsd<_-Y&b z*+kb(vM+wPuMyDL!6<0ge21XxUq^Tw!+MqTrQPcs4sqJM>Qaq3*=SuW$)3~U<+f;+ zJ9Nqptiro%Xov;K;pRI5tdXRvyzy=R1lUmmPZr*x-g;a^1 z&I;DNL~Q{v#cGk!6aXsxtZP;|g2j$Gsxd^L>l|j9@~XDf7z4dpMu^vjM%;1EDzvgG zZ=FG=!6L3p9!*iWeU0{v_3KQbKG(6Z02^wh_NJ{Kz~UhDff zd^fZ4Pl_V(e~fYci>68ljk)_o%kwN?cTvMgdSe5tSBZQ+@J6}uPsX-B4A6jCZyyaK zeaO&q(m$f8y$TREv}IAUyqRLe@}&<~)kjXiAJ8Nau>E_8e!q z{A(v8Rc0Z)gtqP6(Jb#E8%d@cPcMfVs`A{GA~hNOKfQpil~jc|C%saCH3k{8 z-!*w7Hx2iBt7lBhbkDVO%0|nv$Goz!!XKELuQYRdab28xOndhz`->mck1gNaF-!0Y!M$x%)h5Pa&2l6shN>dhO?m ztaLq6>rB+|qKE9Vv}DM^NeXeF$X5a4&)UP_Z-VXEYr6HbIu2Gg41Y?P`)k@aY}4Jg zr-SQ%v!S zusg4O?oDvOEHw0mnngZud zro(M3;%=wbyE|<**@!B&=JxmEH3uAZ6}<YH^;SMC`i#s4*M>p47g62uG>i04w-{`GwYi2Jx|6mMIkXg zb8`5>?--$S38B+(MuVL#QT8JIO7MbP2&Ncg#c`UKjP!|I{fb?qw>9#u-m4wVo?(-r zsQ%sMsbz3U>CF(r!F2~(O!}up@yl9XE4;+6dYb#U;HSsW259<(4X_}2!% zDrB>cE2PqVR7A=Qhp%c_j07sjm4!S^sk@{5s{NFIY_HjlO*Y$1^WwF+AP=15n*CSP zel_?S>q%R!9`Y$8E09BGzs{H8r;T*vy@}?(@)=K3abIgm`*HZ!;l7l*cZRh_xK&^8 zX1*sI!g6&#b1V9u-0KHgbD=v%FS zGQKVSzdj{+wpSXI`3KO~k<0r-Yr34REVD3fyqfL8aCDNrQwrJCe!=}bUw+%#P1pK0 z#ER&8f_qmFH|<&Bn>*2{>5)XNIXF14=Ft3O)#h^`nY}8!{{S3(M+$|Caxv&ldldLi zpF@UO)ViP4N&f(XhIo5V)7swk{IUi(HTf0szxIyMd|Tqn?PAhm8df9`Uzpdw8MUi~ zWx9}^-RqfO60V?3j^q>9pGx%bJTpfRCpEdrUpA>xa=nkIwa<(CyE$7s*8ut-TIZwq zyRNn)n&qGC&3GZWjiW9fIjn4p|7V+mE0-1IxWc5AHV_t!UdJ5BR}$vjpJ z7e%v0$ELV!U-dqOty;3yPZfhT56X%Wx$>iQGw4|%ac%_tMQM*vL@CgFVAY| zOHE9f_UFQ{+CNqBw#e?}7b-D@eh=m=?mrKH)4mM&m7rdHKk?3jml|%Z5x2QdHyf1n zDt7SdaLV0?2EP%kwFZVoE;5Jnuc-bS>;4XgNdEw}t?pl^`m^cuuM;%M@bzNcp?mf^ zaQLc}+Os*|jz0%=Z-d?#{sh;fxP3PJ4Z1^_E#&}(o-W~+Cvx<4%AUl2cliGR!FspDKNC&iy zkbYHX%TUEjVNGIN+y>wtMQlzPkT^NW;+l~TBLp$5xF?g|niKb&F+K52R-E#{5CurA zSu@nt5-flbl^7$6$hw84QjT&x>tx6y2LO{<@{Povg!Z9<b`I6XfaeI6|wb9L8?Qf8h57Md|Ah2PMaaz;cyprTD0O?h(;%Lw!kFINuP$p41(mb)S9M*Kg zMmb)e=M~UNY&LnUdsck$A|_Ym`qwnzWa3~L_b^g9CaGORARGD&*5BEPJCJ1b>s6(R zm;^jxx#a>HG5}SNAbiyjWnx<=>GZ7|n8=F*rBRAEbIsyo(>7T(vW)U@_L5%-&E{>rloy#&c8d%W=+V6A?%&^Y90y zSXM?LA^M7|F>GZ(#&c5JWG9+N4rvbMw`1J$Db2vbp1rAscL3g;o@szZBLzW&+Y}3! zu)i?IeXuH76;OP*$P~qZrwV#{R4==D`MAvia^lR&zq}bIr793OuEu#oSsm)u4&?A=s<28I+h{pcruRGdV+x@EeswnDB zZ5X!AqKFm+VSq8-vNb(_Mey7{W%R%w?)5e2UMcvRpFN&{qdhm8@wsQX)UHj%^Ot_) zo|Wri^7%_y=!@n{W6}IW@heo+BWrCh{T?q-*WSG0Vw&PlK6cADbKmi&vSe^_Po-(t z>6dr1t-YLPM?EoK)hZE!(FUx}j@gGysfLtN#! zApkf*$G#75t$WUypjp|t@<1gA82*S_gm zY*x17IM-15hmtt02^CQ89X|@=l;t;h9+fIeI~DEG6~th1^sRX9(ncKAHpO=D zKs|Y@_OP>p?Zq^Xgo~-=C@PfF8JB7vO$rtxh41fIp%(1q5sJ|xM2AHph{%$vnNi=% z3(ZY5BYDC3M>P;O{IZ-M@TO4HoaOp^R$cAdkKH)prFlpss|jLv>+PS5y3|^xjd5#s#RP-q$>zTw{v}_*q|YsemnPJ5ascO={U3*L2|+>h{9kkB zGfYdA+>ziuD%S5W?;KsW<|mU}>cRltyaD|x-NGx%WOJPNs8&S-$>)mu_^T@;!Yar} z$RPBr@Qm(R4hQ(w@qtJMe=1Mx=`p+Wp~$3+gpGsXwQP%ce7`ewI^Rd0Nl63gn%#%P z^T>nFAP)7*Io zXs$WpdL(qlmv(tgw}-WcBg%oWdy!U~LDo^XOCHc`(tqJH(X4@Bcb=Sz&%5#dn{*5b zx2Ia?m1?d0Cd!;wQy)#yZLP8P&=~h7x)iX{gK@W$Tm}BM<}o(%U>>=uQ0g=8$&{Y; zjOoel%_Ahb9@}Ya;m`n=Z{R4nk3idq2(Kb(E?GClCJ7apF0U%rD$Xf`#D*^6gZe9A8nn#;KHOqWaMBW>C{Q>R8()Ki*j z$Lwy4<1JbxAWq}48TGHDehKPV-aPU3hlO=J$yVOsWG-SLFbai+2?P#6?O&d+b#vj= zu!>35mhROiUVd-xl7HYp^It@KEAf80;cX99@%M*j5Nb9?D4>ymYzYYsfWVHG?Oru0 zK3iR{#Q6Esl{&uj(H^n;PH5JU_^#{W)~_`8I(4H(JUiU0hA0k0rqkSxJ6DP~j`hC| z==Qo6iG3^CZf-XoPwwGo9Aq9!<2B6(LKIzUkX*?$-Iesy z^trmHT2)mjN%FfiucoKO{{R@iHu$T?o;}vQLvyK2>34MO0Coczz#wP19V_O|S6-f1 zE@dpoo=$6$yYT^d!yZZG{{Wv_!JAMLori*YSD51%=#llYy^e-WV&(F8?loC&o!lur z{{V$xW^$m3_?7rH`$mhLmTkCN}@)ON@;?CI_KQHpAT6STQ+?q7# zD;}37wQkH<9D~hOZCcTlh(C6`?6F^!PfD!!O&Quob5n4!-8QXkbA!pMZ>U>8&ZqOP z9rZGgxL^vT+Qfyn<2a)B+q*FVM@pK6+w-0cUK)ckV{u;9#z%1~v3z@E)rHm*aajPT7AB60 zd#RTTxbao(@9q`1Hb*>GESmINeo>No^Hs*R8mI(k6>)C+g^xkE*6yNZ#DrF=c&hbI z*O0dFQfti;_#Qycf5@k;<4E9+p!NFKOiX1H+HCc;c=K`i*HxzL7K<5TE5da9(SSF5 z8tm;Z6ewgl=6SDstkPK&|Eere-WJF?>g z)YkBdTAb|$r89{LL%^uwyPcujq^(n)=`LI-_2;!|y{omj2)s3P#$Cy4VtZXe>ro|XaHpK}#bjGU=C=*jx|>}x zahL3PtyGblL!kRk&B~6+BBf#C{_ypLM!ZQxfxc7eN~6rEx@4i0@h z>^@&p#dG(XabgD`iuO6}qDCJe#bnQ?#PV({ob;=Wc0+;XA7qrK6FBKzuY>$cb)j7a za(0hewQ&>>XO0IIg8u+zl$Rr%=DHk|)tWJC$JCaR>7F!HaWJ=-U~gVQuc2o6S*QF+ z_-?)c*RP}dRhciO-(w~T8wUO+CE7^NuLeuJPjJ@z$O2F@E9Ucj9BNW@cM zmNEXQ{{RsAKf7O<`UT9=Y32#?vP4R{u)zd&Ao2+GugxpzE?2t#uk-%^fPEb$rPj>! z`)k0m9j6(s+iT97{3@(Y zriab9FU@d1DAQBRH!=3E)#TWN=J~oBitf#B?aH|aj+El;$eOyb^X%X;&NGAf(6dIv zZ+@5+r>bewX}4^oVM(koBxAXTKpm^8tE;1tt7yv*v%_+(nNUw(=U$U&u)Jbm*(arW zcIU=ech9AFIv%47+4)j`593@|id{;B=wFs09E6NkT*S{Qjg(;UGh59xK2{Wa)=IL; zFMN9n^W_5CGHzpJ`2fi4Srf!reo>QMHPmsj&TtQE#*i#TvH|VZxu;BqjOBo;ZguI! zWX`J66hL{dfJ**W3FMm1nlX*p!T$h1t#i(qlSToIE0#R==80kgER%}2BW`e`9edOl zautj#0CQaOftyGF)%*%)S(F~6jB!zn064+!Dj@2IKf_7q0t&tX?fBP@et64K;ZE#z z_ouY6{{RNff~pXDvVzDUmt=TB7+~vPMY!s#zoe@^D2)7e4qD z2z*Pw_he%f{{SjE0bn^kw1IwY%YoXbA&~?2GyzgLan$F%P346@DID}QB7-2w$IaMP zfT2KM;(#fXJRYF)oYav?zXv?_r^ya*0IRXt#KdE_r2`nY?8Exh$#4DTtB^sx_Gc!v z1WdB2>frn(^Nf{7e??)|yAp+j*|4 zG$m2lBSfrrI{yHQbnO92X5Qnrb6yp%_`6cqr2ha&aw6)gItu4*Z!R@i{L7{+%zm_N zW#=6$-@@SI4SIuQP^Qy>atBT-+;-%+3&}l&UeWY@Ls^_bWf?gPO{YQ}}biS~rJaSZtvik5@fw zrLwVu%K-+!ods*eqED7F?V91KUUf8jBhsUXjCB$VeSoq3D*P!NCxP>4wJ^fY6-n*Q zU)azmZfau_wlO8Pw_W9c1fHDL`7PpN5sgJ2KaT%}R*t2quv6R~pM`SDvftdt zYik*jSsW@NjFL|P06OH;1JHgL_zL^thm2s*VVB6YU*6tao{+-7dH%hWcVY#8iC%ut z{sHh#l?Q-)E2msr*y+jS!-4XJ9mzbFCnb+^Nw1u&JQ3pW0BK3#zXb>d>!_1eoyZ}k z?GoeoBF;LU&y&y$d9R4Sx1qO_{u0^aFM@FwO8X3pCWNA^*WOz$sei%zzGs1%L+gziqp(l60_*cL_BJm`e&Ew6CZmGO;u2{*aJ-w@B=K{RluJ$)4 zB!5c%F9m^x7<;JsnzdZr*}owKV;mZR+6DQT_pI@Hg)&IVtm&p~a@hm9uGYn|4y~O7|GhUDIgHO{uUGW=7@ZP$?Y_4vRZWtV5KCRDg1$ZRWwgR!h&sw{sc#Bl< z?x7Z`rAlI+Cftm?k&*9Ms+N*(Vy8{YEk){oMH+|0-+`VXy0N>}bp`V@_>2&k<|~9_ zxPk`tOJ z6XE{=z@HZW&Rz$P<2CYGC;DWz#LQtOjm(PJBky1+$nBb60DLX|ihdjThJP4%Q^XLZ z^zgI}^IkNAVOCNCk%7Y=p0)VD`&fR`Ul6=M;V%z-FVHn0mfB?3HxR}GGe#%bxN;5y zGUuOq_$p7@FXIYEQ~P3N&tR$l0L0guS`^f!8nSw~qT0>>0Fm8=p;~Z_N>)nCd*4ET z+84t98t^B^zY_RY!BYvedn@@8$@gx|8Om~bCutmK+crUc~k^#1@q(xaX>;ZzKYL1ME-*A0+=oo2GO;@Kjv$&e04Nvh#P95+BH z8f7 z^5z_l^zO;fu8)QVzbd@5uwBQD(FL117zdw9)LSB_APg_B>q_9rQw+YprBwSr5+1WsYYDjZgb~dX1 zz*lpo>FFe#u>Sz{{#Df%q0b{i+QnKTROdg|p}5jU2`kgCMS4D!rNYbdfmby>42xE9 znEDFRHEd$zbHn1Zb8-8#z^>NUd_!U9ijEC6gw44K$E9!CK^T3myc+3*)vjkW?_&Yd zNo3AAHF_;1I2*t|_39cfiKbq@$#@mNec;$qGWPA9_7y)_igqvAHm-TDo#EY>ZUL^+ z!%QkfWMm4!mK)1i3XTBjTDooh@;FxLI@YdoYUsdqFs^+_$JHwXOIQe?k zPlxX1B(B&TEgk@bcLm_POT0NMjRT>BA}wJx*pH09;x+%>$2KE8M;tc%xC#wFHXr38Pcf zzu{g%HmQGU83gfM`go^MwVcmx`%hYPjH<_$MtVi)e$IR~_}iwLVY8iOg_js+7yyrP zUOoF+e$QH0h~c~V>+ro|^NN|ZyEsV8bCYz6JO2RBRUhyF0D8VX_;2xdS+u!CzjT%& zdF*TSSK)__JOM1wYLh8Tl;kp#*S`a=91wUTiuj!4CfAxeKA+|2dbm7ow0j?rF%6`E zai8m2Ha9FEV`rNB>-MquA)#ou(tK6;R*U_CeJ}b|v_Ss=p~~FjAM?gTorm~O%jutt z?d3-q$0MzLifubdS*_9RG@hy59haJnpqAf&anBX9k&KZ#JWTmz98$f?#@l-5 zu&k)d1C<|K)yUMy<)hzy0$Z}3H-WOkIxT~+cA^rUgY2$#@yg!*PQG6 za>XH)7uL6>K8(VwI-FxGPxB`j{3_;|A-25%&I5I-%`yN{RIO4gE65~HF5B5_3uts&>gPLm0V8%RE=cw- zmcw_gZh225{M-Ri+*-Me?XG!5mW&w|Eu<$Itk#I+s^+!gMp<^rj8a?$i887QJl8y; zWa4uxXvRX{cNI?R7BU5Hp0&JP_Mb6ueXA+MO4&Ia*F4~6;(!0v{(i|$jmM^H2LueS zIP|HUv#wZY6o?pri_lk)et65UGPntzhd9MwF8e~~gVwOYk!Ij={HtCT86r-D(vT8c zoD&>;{{Tv|-!UtYM|yy|sV)X{NUl+Yo}Z2=5i4#FkUgr}G-m}tJ?fG&$Tpr&wJN6E zVY+thKo%u2kV=ewX<%QJrIWw(DidrBF=!P+QD3C8W8IO$hyVDi*6 z{n6`HU7Hx^2OOHXhB(vAX4(f*Ye>YTjd)^>a>MELtSw{3T6T#MB!+R@>s(H`(qfru=^EZ`_TN9SDBO1n0k@k^HT z410GR;A7Ig13~Z(t*j`yW%44&-fH&Y2vd(_G@H=jptmgl03a-T0bN~(hv&Eemef{c^I^z-R`}uxRjMlUw9s@3Y>Y^;b5UNMgwPJw~1HsK`k-9`+ z#11+WR>ZD&T4N$1%Zyc(o;|-P=i0PLw20$(--#HXYQOfpfHFE&6dTVIWd*}oGiD0)&Bs?{sG{=EpG+cZh7^&1f%aN918f; z;(v~GXkuuz+lH6w*){B68#TWP>2b`qu*bU{Yx8r&`s8=_j&0Zs9x-38WmryRrz@Ws zh@IV#-A`3(tB1H9&OrLsB)XfNFC5lvmtQFib5$CAwp8TT(AvfdCgIdDG2B!ys1f_J zNYDBHRfH}W61YEFp>rgJmPPHwDOl1lH*PfqZrfF;v>&Sx?QuHH4XQ^d8p=7X)+ZO7j62UrtR>c^*bN=~Wm?zyaR5v7yB?F$^Q@ojn%B0}ebeemT6;nRHjXV1? zH2H$y;+2PUfooZhQJ?;`IBC;HyK%>*brV>sW7EA>U^CRI9ZejpE^{wuAju-D$7?1) z$2ra`vo@;gyx`SyXn8r{oKb5I<`P+{00ri)3kFewIuHK6ZbuBO!Q+m#9E--&f@43VMvay@ZPTWNOZZr`O%tORTyJAvo(ss8|ahywmYwMv%K z`MiU)b*(kCSyW(k;)N0O6s?fRKX@~m(Mu?wI2p&SOf3XyoDM0`D+R_+JuzEE&9gdN zWin%LC-9|6QCuk<4PB9n=O;dx;-KHMPn>>#{c6%AvsT|~FC>psT@{?t$Mc3Y%|$41 z0Ko>eAd!`J@s6V$S3+hi_P+^h5T?|*BL~*KN=;)+xR1*&2t8}&J3E(Axw1wnWYuM9 zI4AR^DqO;gvGmjy8db=|droK?97s%&$B;jzcwV34Iqib{#=SF0@uC=Z+%`{BO+ryx z5hk0vIx89e%_|VQMQul~z{|Cm0n-)A$>J+ph#ULZuQ=5&h?I!hsua1NqjR>1_Dh{h zM#@;PkHrgIKwY^(UIWZ>lp(o_{*tG!=}T+4QcPGn({`JI4{t z5hg%0>&1L`4x&#W@=rLeNHuAqI1WJUeW^S?{k+F$y0b^r|!J z1@c>xIIh|>xpXL&pUK8CBcHtIFEoWp~FMHs3+(hVaxyEFF3!uMP4o)r<@M{^rPHLb@n%NvX`FyP>i#2!Z? zz7_qT{siiu2DNK_TTY5b&^2tml&*IaU;%g}Abs>FgMqnlM|^tU!T$ghYj-OY+J)lW zD8c8geTtCyYvQhnKY;vUct5i*7i-4bOCyiDEM)!J5S)YDWlsdx=DF5gE@wD=OYvR5 z%kJuX*bGujl3PE+&-~BD;W*pR8Q@imSxXR&k}JCS!| zaS1;xhJ~ylC)S%3q1>EjsO#%ZRFUQ5Ak`;la#}pAP_j0&G7*tkaRxH50CC4P=-U37 zzCSQ^9@XdN76l>B4R*p@$m6LCGh@TrgPY@TCqu=09mG+9Sl5tRNYLD^!kpmOs@vQx z<*Joo>w#R@h--67PU^)Xn;7wd{(4f%n^SaS+O*|`l|4zQOt?*@eaXdfxRlY(NeFj5 z)kgBi1o7`$vhHHVMO6`oIUt7bSjr?cVBA96zHhtK)fF2)Wc_P)@jg<TAe9JY~ohKt5wrjm7XAJ%vK7 zcsT1*kKVTC>M01Hc|^-#(jHZw}POCCsYi!at2M3z>tv^e*v4vo=h$630wcYKb4I!)UX&O!LdAEiB6X|+EN$(PN|Lx=z#6pzw#|$yM2@J!!!7MHL35hbh9n#X_NwrzqU~eNXu^C0ANObV|lRV^4Gde%Jpf+@tmhG{Nu83x$8X1Octml<=; zc+b+P2I|)eSg`3_W}$f+XKw)UQ{G>eY&TBXu1i(A$R&F8%`hlm%3>x=^O~WfX*$=7 zHQR3t_<9$f^726pF(4$fH&5{=u;!>wbQ$&z)%5p)=8NEFiyw-eRCtqAhjcrzO|`R* z4mre=4teEM{p#NhhN+0EsM22Z{5;>1{{VnxTC{0F&U+)>rm*qH!JiAo;6DXu;q7Dn z<=y)pZYLuQ`0Ru3#y`8yQfuSSiXJNQ6oH=g*<56mR^qz<02q9K@xHHY{{U>g%8}Eh zd}HIU9baARC2Z>?0U*tv??&e1dDIco@e8473&&=tp&>xr26%* zCXpmY4BuSWL3KOa%4H;yYdUmthXXn8Ywan)MOhwtk(nz3o0uL>I@Jh*Z|8&9bgd(8 zAi-0{DXVXjw67%K(`JdHykQ#~`~Lu1WUUx|p!ciKY8RmXwO!#3yo@pLSvJazjH{63 z1D>X+09bAGHMK3XpO+XlSL`kT4aH?6c*zt*ZrD-*^r<=$TO4sx{gu)}RO2-amhzk_ z;AfIQ&#h%C6pHdM@bY`&sWFL1%gC)0Z0F`J*N`(&v?|yq?&q~*CTNOrL>%n{JXVsx zjBpMQYLeC^+E@(din1dcuty!M2{Nu`%N|>3AbZwK(QQ-D(zfNqVB~UmthvaRPMF6y zu4%Mw6FrRS{l`VyVshyut-VT zMru?|jfTM^iszd&MJBbt1x7JdMY7Ilct0L5+ZpKHwV#_hbl(B+O=TO#V*|exW+zn{4WC@p~{3ncP|&#xY}Bp0*C*PM5&QVvtQ zjFglR#~uFw8rhEWNV(2C*9i`=RYJJVYTx$CG1^BR>u6r&Ngj+fuPsW*a4HD3MVYq- z>0V`Vdy1tMoMn=o1;>p1js;>H=M>yBuPx%zXE$f<{F1)!nsr#m=8)q$*;~hS=7TIE4IN;TJ5`9bX zx5u+-(#Dd=cI0Q8`zFs_&^7x==dr!=t_NV>`)H9l{gkcIbYFN8qV`CHP6a4dSL^7j&9!dB+R}M?at5 zD)82~;I9#0cvHl-#@716Qw-6!IAQ2V1QCJ=$ROlb=>Gu2?;iMW;?6aQ>^yKQp!nhN zqQ~RcgW}YDG>iWL2~Lw9+fW7#)IX^Zx)pzrFsG zFRrB(O}%=5m-&6(pOscJxNV9sJ!@te(U__H>zih|v}d-NN=X|OWjqi;v@YgAl-3}6$A+APhZRx+`wuk8_WR51aq46uM%mWWn$rTp0(dcFYgI0f_qgx zLftRJESMx#wCBpqrygW^Bp?PofEC<$dSK{dZavL%_x9~(#SnfUN{+_v=GJv`c=fKC zr6`=xc9H7Roq!NJcc|PWC_Dp<)2*ZzFe?$C*WRR=Q3uMrR|7{|Mpu*+@^YYYlT`}& zPx9cN_^YyRAQc@>X~h+RknljPoJeTr=YmII4gjh$LKT9j9<{qF=ae`;wMC-{ts(id zz%`7bLy7;;{(ZctfcZ{ENQfBiek!uY1`j{}s-0A#We$H@@z2i*(j&ik!SwA@nYqM+ zgUG6Q+}o7&t2qSxt&%7aIuchsd8inlGZF)X&{Nt#ibyc{z~mT&IzFcs^kHWS^!d_QM3`CdcAFGfllwk>s66UspA=|Pzg7I*!QbZ zDMgbH6N6Xb5_x35rD&pPqDWC!lZ*<%)OG3OQ){Ow zc{*SUxYpO2jGHsEGuIvKr?SxCvRp(yZ|`@ms6{PVqJifcXT#ggYUE$*(qtZX3iZ7! zN7J;pJgXSOV}Zx@tqXXbG4l+QS7SpE0YE0VbCkE(-W1C=5g}sEIsX7Stu&ceGp{IK^)moiT{? zZg9X14@$EXib&ueb5SHhR?Y|>)uNEAK4a}##T&*VeU>sq5zzFj>jDJ}&M{GyC*?d< zu&s@x{Y7YzvO=tAi@@k{PmNYk6!0o=KuxYPb5JA~JBb73?OG&SM9;_z8RHz)*cbO< zU-YV@Vw_`~;O49{6jIC1ds8TSM&+`n^Yx(;SlsVlm;5Qn0-$PfU?DBLo~D2+&2X!g z1HEEi-34EmWP4Q7T}JFn9+ibWtl^t>M{M+}NDSMn*oqK3Q@oSgee&u0bEtZ$t~qSO!HPRE{QB-<2AYP*WvcR@khpXeiG0| zWVvtNNZ)oi*O!sc_fdKMI+{(}Ru(;z_I3C?{yf&?@s6=-ZZwT0hsb{H^PW;J-zfXi zWB71Ch*xLvhvDY1FRpw^uG|9hF_DQmB%1n9#-1|ppTchl+Wa=~81n5bV=5#k=aJ7Z zBV+iTxjvi@g1!m(k>lN4R)x&Y<}>bV?znF*t4(`ZD>;5$zt7LnA2*uf;Wx<_biY35 z&VDA>;zyDQ<0Vw}`d7}{?xJV8Am@(N=pH84*~`S75_zvO`x$p6o(+DNhNa4noo@S; zV^ndJ89j|fHKd!F$3x9(!)jw<59d~q8B<{%{`Jz{gx_5bSM2z3q-2j;akG(5>>SrZ zAqq=49+b%@K%*YLt3G2zBb}Dhqi2!cofHfgXwN_A+O+4IH#h(aK%+kRtlW(gGmIyG z00)fJb0APyesyKqFiUprRArn2fO4X;l!}VEMqr%q2e*_BYR;~lD~lxIE9 zT7pa8mkr!?sxLG3;GWgbBSc?$kT&hzdm6BpUoO+o*FE-<*aUtinD-2cmE=|uG)GRC zs+nwoihuT=A3l}H8P&%;A6kjzkaRqI)^;rEWz^%`a(enxhM5AkdyC8siWAYTicP2PIw={=iwvSMWo0FQFZB7sl z++g&s1#cT0+k@7X?mu^&^*^l$RyS^E^9Df~Jk=jIOcpKI)84bz+Ssu=8bb6!P^40G19-ue)7 zMr$5PB>=HMd#8G(Fqy_s{sNPB8i}(d)G{|5=e=e;$gEyZ=l(@yF_~18o@%L_hxxjC zQfz4(A8lL|T9O&g!-J4|S3dH_cq8WPPZt3gFzwQ$s~s{(uD~um{{R}5u3Wh1k8W!P zFCi)Nj1$(QYoN{diS+)ICN5KP?$r*_!0KufeY6rrf2DJh+%udp>&_{rcE`$i6x@T9 z*^^L>j!3{2N?YYD6?^l=WxKIalaq?7jCY(5r7mIen(|1kf*S+AY8EW3jQikKc;*cF zBL_Ux%_@}MPd%$d#bN6sjz=Ab;ZKf72P9&ulMu%ob^~LFe?z` z0g!+C^`UO0E(S(<^{wG^7Keb!aGB0})#xr>Hwxp7af+?yA|FmMQi{)bL=nrk8R~eg6||Io z@E@&HNvksnH*@TNg})MEjiZImI(4tPw9gXDK9-l-Oolm^2jnDwpRImC_;&V7JB0;? zbJSPZ{{RmBc{EC}H_R{xTKW9P0H3`RdKI~JXM6F-z*gT8wEMq_{uziztynLatXcts zaG5!}j1S^bk2nF-=l=D4K5k5ZBA;Pjq*fj}*0f9bb=?lzGr+;sfX)c^%g>0Z+G>=Q7rGax+F7 zpZ>iHWDmG>=~^aa(U;-@6o9e=pK9Pf(TCZC#d?LptkL;$C^hH0t+(0OzA_J7S6wOe zIjX~|JG~226F?&;kUCdRBxn1_it)VbDcCGGYfHgA4z;3)r{!Lvi*$91_ni+~v?EmKrx~kAr4bPtL%DN~m=!!$^Q&$g z=B!%aF9RUemfEfkI3uNL7@Ec+!j4W`f@?-Nrcf|A^rt7z0|B35SJP`@9|POe*71p~ zVlAo}5wN(eND1HNk?op|93p(*g=ktbNO|fy0b52U@rkDb2_p;NwOfkW+4iqt%`LEW zU`X5#>G;;P1{kN_G0&xM7@I~U@j}R<8#{V)R^YN%eh(dURt6+cxX-z%Ezlq)GmlJG zswYfhI6wgk$2FrI1dZz5vHmq2R^*af51PLA=Wh7UJN`AaVr>|Lae_wCz^w>343r!m z^`8lMf0*&tBDbU3BHP}yNZBhD*XAQXDfRcOi5iw7mwrZ91&2AP6LPlVFiv@`5;RD& zB1aPja(VTq#d6s4*QHg<$or(n_)@xggc!IUw8mUHkLM>9D$lSjO@h5`QoBB)O$)h#D_TiMPy?fKU~cW?>I9Al}fNDTYCwMAm2s(F!`!~v`8Rn=4-^(1Ywc8o%TyCZWcu3nkRhx^+zGrer z9cz!g)Evw}JaibK31u_N;u!MG#0{&-B=!QoNPZf4J-!L}3GJCb;-J-#HlZi{@?NQK zKi%smt8x4yxewUy;EB~_Z;c)=k7b>cmD16d{cOwDTppFWOg?waa8Hu%!xM!R@4 z_@tbYa>v)X_pf6l!Fzh#@>)l4H~#;Rl3xB*C(Hw z9M(f#w4)3%e@dY>@~I@89+(s~3O6*aCkgXsrxij=lqBsu9=WW!Z#cvJ+2W~7aThJm zJ#o^c-9)@*+D_A-N*d{x2lcLfuSq-|+3QrLyD}0%BeiDsHc>XDzg?Sv=qomBoz1mR z9V<2FJ8&C6{c43Jje+m&Su)gYS(@V~9R_-uNbW)4w@$U2BCb`JT>F@mNtdc4Dfx#8Y zHg6c0e$o}N;Qs)PMfTKY!6f5@^{n|PE0U*osjTBO1;;{9TEbH7YZ#l;-7fF*-mA3g zstTO{0EJ@xqCof@`V-cv$uZmj;CuJ1B|fH#2TE?Xrw0TIV$ROl1pPn8xzTYZbDo^! z(~RTqWR7xk-m#RfT8Yu`CQjd-4DYv&xvpVut+1xTS&{RKY zjhClB)i?7gBXC_Fy{SdZf&#E3J!=(Yd;mGm^`s1hox^WzP%CP0Lv~mdY-6#js^pW7 zao_n@FtXt8UcG8zF+3{~w~8-qhhu-v1xO<#XP;U|nFlM94P&I_jit8{{OZb1uazgI z7h*ITVpoBI(4Mtex&we|8JR#C4Uy3HtoM+Y^9jx>9wGftLA z2M9NRp0!EXNOC*S!3|f(%*Pn%ibCffBM+@X`M0G-lx(wgC-taoyB5TSK*+#8l;BG8 zF;&q?P)X0f_*B~l)5!$)r*RRqf#e|P9gRCXyL9X+2JrbqyFLA=2txEDzJ9cm8y$=T z^I(dL$stc9fz#_&)e5%myyu_kPgFyN>CQ<#Ppwi!wr8My*khCJPGx=BjPN^Fn!*ph z2L#kEipz{+zG~7JF-I~3wY&3IM5Ppl80UfOTS7*17~Er?zt*fnAkPQhqg0cUgE;5~Dk#X$%g%9K z5o9y2u$D0%2+Is-BD9c4o#2f6S1oTVqVE;69H4OA5!hEkmsE4Onr0B^2!9F^+0}Re z=cO_iEDybE!cnj#I2|)x6xo!FVvmoReaWa)G7m5hsIH#k-3slDgIS^y9G2h#Td5Ip zIAz@7Sak37tzwXz?^gyG+(@YlgC_sm6vTJ zfB{|E>sxm4gYz)w(y2(v=v{@Q83f>tN7l5W1yr89yVgTQ(Tp7Dtss%|a7}A-C#lfH z+j-4uByg4l0qezc7Uo7f56$oNt=nYdc6seoh>|;9HSLN9W#gr6Vi);va5?K-m7_AI z*v@_HTuK!(y-Ce8l510GVTLj!TnzNDMeu&EmUl?9ISNgAY;Pe0J-ur|FIGHbh6jq8 zkmik<3F>~Ld@k_@n{^t+BP;I3d!6Qq9kde9rP)QO+TFy^-P$<$WsSxe7=EgHs{ltL zzcjuWc+go*7~V!X9jo+9;m3`jxKwGMlj~m>n&6yob$GqkbJxOG)mho2^S|S#!K?oO zg&M3kwgOv?3sVK`Ze&~>kG~9^yx57ue|TYs2ageoVxR%rn*IL(*1R|1-;16Nz4&|M z81rX(p`(?4^fLj3nLpZIPai=PX8N$_XJ_kIQOLa&`}cSSk_<;@x8B#fM8 zSaa>jJc{@Vuym?Aa`}0$^Id-P>7gY!^RJo3+RWliu;hx^Mpk{#&{r!Ik^n-t%i6T< zrfdZS^Gz`{bqRK@g(n%Su(?&_#~_K$Ij)($c--fT<#s(NgBT@UQ+qZ3%fo;c%N2*zeU$Q7iP{a2|yewDOhZlXjZMvN%PHKl13VRCzO%|mYo&w{nkS}bY+ za5L&_c*NE*Ck8PHRpTSAPp8JLO1baWl{~oEh0nEKwng8$MtSR7D4h|Bs~Y^E50nmb zR#8LXgTU=k7?IcI;C^*zg@r;O>)cj~Ch>`O-SDP7V0zWXMo9i{dy0Z6Syy_o$sChb zk)O)>xvdg5NOBn4X?`nGVG~9+kT7c=Ib7w}f$vt0(>MXLc;wZjT11gD#Mmc;R($A! z;f_y0F;wCnYVRkJ&J8}|NR}W9l1F-?h}6hr>yUja+9aTcErI#fD6;Abji6SN+(Z<| zCm#6gKoQD!p-_46ipdvi6A=AzQJcj_I}a6+d3LSJ^a80MFG+Q@{N1=6{*}*aH~#=P zzg$&0@4_JfiskS1Rv?UL=~M%6H3cN@Ir>)}eSI8cf_dsGw^uR(SQ_H>3)Xo`$@)+$ zmTxsy2+p}bjd~BjuZDL&7rbvCl`&{E%Te}?LgWwiaD&bd_uuZI^*(^tnCW_MqvAbU z+d$D^dz-t-1aQKfvojn3KN|fN(KWvd{0Z=?{4&vEYs<%RHl=qOU#HtZ8qs0;gK5E%69k_x&+j6r#;Z z9yriGdi6Cz=0#=e*P6epN}e4dje->rl;O25K1O$mfCHsoY?ErM|U6?wIlq z)YS=ZH-cN7)@`F`#h1ay%$$$uRb@NJ1A|os!5qeNPpwe_`BD0eV-nLdE=%|H ztod$Ldi5E{d{lD2@xvaqPGU-o_O5xz+A$d{%vT(N{xw)#Dd6+ZO00JjExR7)`cz0C z_*XwpwaF=JZxmR02ss>%x&Cy`lQ5q*X#|bT7%@PBIZ=8rkOl$i zPU-FtaP(;SzR&Odcl&3@vF*OE=e*AIbMDYSN~|=zk6`T3swtCH=i%}@kl{D_%#Yg> zwpW$QFFbZ$*!%_mIO^I&8_VB4Hq`o#G#fe;)oAUO{Ds9^jWpQE$%H*~M>gYyQl%Fs z4+}s00!-J&dP@D9J;ug*a?5u~CF~c}yzw2MYKhCojvcuqlRlG<05OS7HvBvFq9BMr zJsGdtny=dFBxOvuG*h2}&;Fq{lW9tTP+>I$e(FCApyJ!5+g*MoYv{B0jLTYM->APJ zjfgB6dxao#wGw>FrVpDd$<)s62_!0zmA2IZ^Quk?VIoB-;)2GeA(+C0UxC?3`_y+4l3PO0d)3A{bDLd0?g850&!&H> zT)!MZ-WO~XJ&6IaX;i+gtU<7ayBqOIS`)MEdH5`D&2!5u>;(bZnFcB#;Y;7J@9s(KOJpM?_VS}*7_I)uhl!zmql}3 zG3Gx=U7g$MyJ58cZ*{;YB8!&{dODoeB~jHm>S*!Y^tU zjf2OUK5pk5S$7pj<9zGJVEZUil?RqlK9+7%fyZ*kFK*)B-dl9(glfx5`5775kMAtT z9r=k_`l^^dz_?$$C~GgeWEMb>GH;PQQhyJ4=p@1 z?s>4f(c)rzH!XR%YxDyzaH;>FfCcfi){~w2`k2P_EJ0DBD?P^#v_GmUuT<%OOpo() z65jG(&#N`Iam~DDc%P*xP^b_y!9(jLPO9Tgq>Fw%y1=irGjl}%M);spC@Tf^+X z@P}a0)FPQhdX=S>L`1CV_=#T^R31Bbze@{k1W1@x>N%c&V*bOKDb@axX+q^4c5GQ! zr{+zBL((3%_He%stts7)7g{7|QKOY3weUt994jy4f}9!f8Nn$Ld}$0HyPm2L0$s(0 zY16rEhh&N9a^X|BCDxw+U0+j`KFnMcf%M(b&lIWL%S+JdS`&Mz7ST7^LuIK5ww~XO zm*&TB)>AneJ(WY*tvJO+=p@U(DNUExWMynfvY(fS>adF0PbjM#2dEx=ivou|zSWQQ z&9~idN(Q!=k3$7+USAS~N)&4|OD!WA&TW^VWORu3%BP(hw>_z6ow@rpjF;eEuJfe!-MK5|GM&(7O}*@cW6zlKK#M7y~eI_ejyz`Yr?Giem5bFZ)$cov*^ItvL^_nd~cDt4n zU%zH0sl8!(a8n$@Y|o@LWqnsJosK#PFRurJ~2!;OSms_|!LPqf74v8Dc% zd~aEm)gVmutg4~LQ6$BrK;Y|tCbKFI`B{LJSf<*rL%)FJvrE*{b5BvInkZz)873b^0s7U^fhPMwI#neS# zNB~`nuRr2829p({IUVA^OE(-EN{dA6mb?DJ=c^&lnMVm{Ib3~wiher&EWuXqfaVXU z+I?eD2d85v@0FXOpgY6de3rux5$}BL-^b!=i;{w(#y_&Y#Cgl_5A!y%dWvd2N*VG0 zGUpxUF6)W}$4oTR+Btnplo-&4EcgGk2U7&mj5kngKmAxmE;h6@lv(miwrKoY0LW-w z;7j8#B5xk=fBccr*JltYwp``>TuhNbyTlrtD9<7zkn*cZ+S^;5hDz0^IBv~b8w$NW zIWR>0fqI+R@s~nhHKKA zZ09u&A-lSB@D5p;OZBzd%id##_foI|rWc<{W4>T!?F!rfj{BC}kLVtgV3(jhNV4gF zo0Bzad431oIASNsJ;_h@|HGjKTUd-geLfbM(9EmfhC>#KgToT*BbEHIcJ$F-l@Tl6 zUX7cfdQKx(<@&?Hi2c4Rgth&i_FPD*-3r}3dp9#xTaM2teZ`0k&HjV!a~1t|8KUVX zuaB&<$n&*v|Nj2neQPEk>Cu}uU+BbmtHR(BDxI9ncPD@sx`9I#T9n>}6lEqa;+oRUloYbn<(RxMhFyh{;Ym-pS>E?roui|T;pM7yK4jbux zI5Roz`R4J{UkS5I$UFH%y-Lv1O3^E-xyOmrYU)-LP;(ID(^PK70H0Byt|RL73}f;& zo_-;5%0v>M45!S=l?txmts36(#gkBE@tie-rDt5-J7~}C(@yv*Kr-mi&T}Z&Ib~}} zh1CoX*KB+%8ZQwi;zTlCmRbvE%z{i+;7T?I6U*{4;*6U^5cyk%c%B(k(DZ zwQ78`Xey)5*iC~}s^8w9dDZQa=T)-5{EgF-Ndvo+kW`;B8vdo+|E+L>xM>N?uzY}L z5A>|`aX-@s*_d(EZ`=W7vwdT}1+q2?9oLL5z%aVE_kWU(+ zsNxe_79F)>mlvjRcL|m-SAC7IMbcqz{`B_d(7J3%0z>!;K=s%AW6BKp6p1-+`Cq4L z;phYvBRuGPwcZtP;8Q37^NHc!(~W+F2TO>dCCoJS_CA9`SuUhdgp6;Eo|&)6`)mfTk^ZX2Uz&38MkZcy5}OlC3yDUeW;83gQ4DwxkDQfUK@)M2Z7`~14KoNxsOjL|X$Q-H;U zXct?c7sy^YQ3Ey|@0-xlU#X8x1j78YH2Vy^B;L_^TOZePTM#g^!8 z#T%w5tBlqCY4k~5m``8m6B*F`d5(JX-dZg^5%+3b@G&B4v{vLvYPc%v%9_Q;V;o&* zAFX=Y5NJv?5hBTeB8~Lt4?f0N8mhsr!o35_=D0|Um?s)IKGjCzpGap1t0+9vQIJ82 zrUg)bvorbb;CL0pHKiQ$>`_DTjbWFF2l&`Paj*4-v_ZT*55r!_&Gd1YR=zyD;6iky zD#T>H-h1vZftrJSS3M_MrwaFi+}a0Y*`0D=9UC2X__@Bo^|6A+Kh702Bxy@MJZzUf zbmb-2S^4`RD{ZI19lOKX@qhOhBr*aoK>1qHvS`y>|IkR#zGZh-jfMPo9bL8!gN`9w z&fJSa&Zo?Pt1IFT=+wi>!V^OUh&rBSIWXyvUu_qjnu7G<=DE37cOm1Yd88$q(;tWP zJa~lf*7{2y2GI9I;0ThAAm%#VHT@4D5~f-YUuK;iKKm3;86OLwnm}OV@GgKuL8%vf z=k7N0SC)oVSIjkQfCs{|la4e8pO<>OSu)evx@QD^q_>-C;T%jM7f8r2w_o?Kw#kdu zj7qlXPg@0N>G6ykrn;wbOs$Ux3WCB}RT&eUAL{}2d$VpZP z#swiPf#q&z>8a|LM&Z$c0zJOcy*|`IhIxeQG#R$4io^$x^?|f(E!Z`0@z&}nQGgbU z`Y?@2`46Y8|394MzRy49PL@=c0NB@8wGnm&(; zRaU>*1Xn$i6o!Dd8;$%^+lgdOF&3^+{t6-YxxwKu3jTp%gYbL({+aj(Kq5hMj9G+c?Hi`x}lMiP3zm#0wi zRUiMgw1V)!Q-Ca=M%?Hf%+ft?o5nxI@Jh=X&I9Ap^H}kn zt*h#J6!B)7iIdz{4Nq(KCpx+X$~$x(D+MBhB%?JOpz{*;L=M}f2r0YeAZr;PEowSB z+3<8+0S$<)&rG44ytB|T1+hPwV9n&LKEi@N)n7@6wF?}8o^fBIP z`^!hK+6N+)5vf*FLkGiYz)#|hiV~j*L${;$yYc=?T)|!(TzObgyDZVnJ;&w9U#Ov3mTb-c0J+;iQUOw@+md}(6!ojNXx7yDK>0{IkmZTff4^HBz$wp-|u1!80nl0E5VvA0_Q z$$%KAX}$1Pt>t(LBXcc#1cM^Ce@D4E2rn=r#&+pBdGIF`tG^kzDW6>ZQ!NV498h{A zdP)s*tL6c-hBxdR46_sahx@4yWIUfp+{Bi=EslS>7N9;}(w862gjQP{NFm1uh#BR& z%&cFbdUGg#k&F&CQGnrPtwp$;m*#VAJmjeAi3li$b&!SIX!O^(B_6F=PLlZm86WjH zUnAf~Y+s^Sn%wvnF;d)gyCgiS(W5mkm0*_Q8^k=tx9}+Oc6NUnvAIcm4SacMRtI_( zVY2}X#(d+&wSdMQFtriaRx-n3!t}z4$7qXlKcm<4G$P&L4lgCOs#0nsNanF(C;kR9 zNG#yr0lFD+I()kYQZaau%7E8jdLp7Zw34UXCIvcjVAntrTaC!mfGt$tp$j;>iv zh{b1{ZMYrj2Kx-YB%0m0W^AyNmfy!-u$Rg0tzCW)N|AxTp^7t~@$%uk~4&~l1 zZe*j()jA!5r$tU}@;#EM#e>CW(zB9yf=_-#74_rUC<(h?0cT~Fg4%7NcP4oq z1Z^8^vE~ak9!iMuAh$M8HjB7d2G3LEi~bqVkde1&pF;=v`Yrs&$cWonx|Ng~9}<1- zNrfy1!vF$?4~Hb1L<@UH5D`pjvPK$4GI$Xuk5Mzq+{u3QwcyvzG=c$S*v?vGsmG-i zJKvTfMUGa3SlN==|4?j~)ydj(aedHeZDi;!-{|&=M1f~(KjBxLOk1+XP@Kv>vi7Z3 zB!;u%>Q#!!T56ns9r3RI)shm@je*Of=`-TIxa;Fyzyl!}w{q=Ia>7hG$mPk~ojPBB z$*~l=%H6Y)4u?ZIUgil!MlO$B%8S4fl2T_cL0t=ctrl|n-TPNUCsn<&laYE&I=9Y^ zY!>dAtMrpFZICqp9UPG&DIYNl?Zk%^NaGYj+32^?mO~n8`GhR)A}Z3V;SbwhU6w17 z3XQtGEH>NyZtoM*zFv=cZu^;7%JlJhyjUf-?d&h#*SWJvfU?j$0iyU5EVFN?o#WolMfJ-mqv2#9xI%93$!_YlD0TN z*o!}4Pk=lmB@lF8oYgMh`LsG)amfE1qt4Pc!nMEWS}097)U7%^fmW#&_Q*Y60KY>G z^y%QpC7By5B~Jo-Dl3JwFw_Ro#5lrp3w*voi>qH93~%dJWS}63*thuc*sS6i|0BGZ z`P$62H;So!7^_JgLJbIOVa-~~1Mq0q^q7;cs{FH#&GDc?jy{=R#_Zj6L)N;$oW4I+ zRl*M0J+xdL=$U-2-iZK~1CJZp0p1p`ES)e!22`RvtzaP8yK!xDoF?KUZhCM46ciFF zG>EclLq7@p2*!(+Ln~g#5aKpqd8TUZ|y*S5|oMBYX>YY!el{rx z=H&f_TEDQ1Hu&VqIG*!K&i&nEcg5qGxSgY&1k96D?MeF+Pme8T5#;K*Be;}I0cU2T z6+?a`U~$Q?VOUTtwwxSFdhK^0#FZ?`zw|!!%n!TitsMUGR{VJQcOHHq98Bt8;eRX_ z+st{KymmL!Im+PU<#RI_5_N&z!F$Mmy^&XO{E{`}D$_YSoqKP>0{dp>s-o9(`Z|=A zL-Ir&AS3~?uL#*u(b5M*w87jWs+shoZu`G^Zb8R#9bOE}mjd1Ts5kR$&CPk=w;Ymw ziAc5+T_0PAXUF~HmMv`VQy25AoL!%eyCsDHMT?>dGdD(BswJ50o$nb5aYVpo=4shO zI(CY%(Gs+wsLSpTI8Pic*B0Ij=?JNTs&voIL~@H?FTS6O%LX0Z)1x7r64de<$DIw# z$+{N8oW)MPV5lCr&z?z&@$<-)q-1F$4}jhyOn6$_uu0acF|AC1IyY8lJo0~T z$h!V&SZ+YMS=VI-)ezrSj~tnRF}0jDYlhDASb05Vmrf?;gVo^a0qz9OW#YMj4W8R; zlUTUt-9jX2J&T67$ep!ibj$R;0yKG~R$n|({eD1-u$|V&?o{^Z>L)*Mo^PXp&w^Mu z?Pobzpz3Gp^}O&4`4UQ!#nbVMejHl{$zjv_`coSaX1vC3iQy!lMc3=Z-6$ztwVjEw zy8MsHxP!G6pJ zStL|M<2hu^t59TyawZog-nKC4;(I_OG3XoxFPsJpb{Vpq-rmH0-8hz~&E*i!JRk$7 z-f)G8**71zj13zKIX3LOQRyC?)O)b3bG$Q%w_LULo4>V%Da_^9ZyV7I2j^Y$?2I90 zBAJjWE<(S5vji!ST`RxgS&#~5`CGA##@u`$r=-}h^_vC*$=&|zDIBZ*RxW1JGs4E4 zDp#ZX2Ykjh%c#y#Zva565%{fMm#Z3_e#Hxhz_5De)@$ zJuJ?H6YpvI^-{)w^>^%%G~-c34BI2Iip1YiF`XvQ)@l@XmKQgk@|rT`O!(ZOq$_DJ z!i$ao2A6ntneQlj{e+;N<=p)DD@eTT-VJ+M z4yqiTNA?vcVI;X*1;W__Gioh&3&~HYZAu)4OAO85hKBQXOLKlaX1Uh-M{}_;-hAS0 ze-D9$m?Uk@eCc%eK^9gmsz>W?*yz&^z+Ap3DzwWeD-PEq5C(zItx^V z5=n(`dkYv+VYu@IqJz**(x@H*ZVUD=DyF_v^o+#|0IzPU$FQkrOJ6bb0c-PznKZQLpk08eP{T>vFwa-t7? zQgW57X~bwE zbK+El_!9mc|CK9H|NJNodZ@ptmxIUGYvJ#!4!%yXJr#|W#0-P0*?E}5eDvCsuW)wY z+Nlc!@jDe0EN+C3yr%T`e$)d-4kzxy`64QdosEB8JDiU2{~s$QT-!^j6`S<*)Mn`Zod$oF8v%D+U#F=S0J9b=bF|; zK}o31-Tst=ohK$CMTp*Tg1(r}#enIQMN~)o>y_Mwp>LT^05fC@n>S3E+qXL#fEt$) zW|9fM&(Oc{!hd(Dyu1d{cQAlnI=m}=_;i!3G`riJ&S?rfesxMFni53#_1_D_Fh?TJ zqEI7|f#zzez)cek-BX7&@)o|YlPN*HHoCz9wDGL+m7B~VMy?G|j&r>6yo?Qg`U?G( z7O*F~ToDwb#o*!C#(at~9SNl@uyC071?Uhz^#AN7D3WZwkM1vP-lk6^=(C1 z%!@qh+T;9{nDQN_3!?RKvT13`z?Q$36eN^noswFVinPq0(zwYw`I;U8Cg4etnVv8`ltUbDMQCDN0 z6sEuIDa{N6M3BA)F|!5;_8%YDsifWhn8|UNy33^Hmx(KQb`#4gKeZ%nG%N`LGrczK zJl6;M)>3og$5s6w?QrM>Pu;lxeDAQO?Q6fmLq79c$_d@er+{p^&p@S8Ma@1RQ|1Wt z{E8CglqDXmnEnY~T_sIXJu503PsD-lTNEZD-g-Jzuv5;LE4+&OK;-Ez#Q@=hubiNB z?B$7KQkOISX<9G}$*!Fq5o1g)%2h$V^e7VbcFzpnKHFX*8P_@|vjE;lFXVY@<*Vu> zgg01tgIZ534MNM97ivjmx7 zz6F;ksGEOEzf-x?KjxpqcN?!*kKn)gpm>?T-lghdRLM-?ek9R3-@Vin3$sTf$u+J& zuN`(JS!!o-X8Jp_#8-Un-6Anf6EFDa?JuVs{`4>UM#)uR71&;Mo`=~_N}S7n@1b3Q zdkD*&3@Babgsbj+ce^c{c_riAF~2nuQ(>v@9OBHE5cup(Ijo2P5W5A=Zr19%K8bEN z4-v}*n7ZE>-jY;b?yinrTKvY1)s3&|gZj}xo(Da6sYeKXB_HfK15*PM0Fv!@)UVyied5o|UEO4L72 zdD~>CWFJM8_RD6VR8Y_UCa38f=qhwud>{jlElJ+G5gSpE?vF^KCDD2F;;b?PMo9)- zqN()8INLSPzo)RTmw2f4_5FoLS)|q@z8l^9xiaQI7?qO_C`HlKVVTJqQQcq2msxdb zU-`V^O_aXzkM~M)1>z3Z!3PDA14NRfh)sbF!0){MkVrv zwr^Es8-q-&vpQF5wA!i<5JjV3YyqAg{|)Hqgcz!kVEpsHv~5n){%cAeQR|jEShXyd z37L5r;D7LNv{lTOF>$Oa3ISPe7~C1Ot=Gv2K3_vnf3tT_M`E&I9XO*8B3ZyFS*uML zKQg0mFPweK?$24V_4f9*0*$bW$jFd44Y`3^dbM3$y$<&J%?(p1{ilw3MWA6%!=ve! ziZ(Y7GwNGLxll;G09&2qcqY2IH{hqM(^i!Xb(<7ETlGq`ZcG`+jZPjk-cLGLgwe`} zk?m^diGQ+5|DiR*#^I;yV{)o4BZrxZ-{Qw6U!BS?p#09|+M%MyNwEk}+ zkZVr>Jm0)so_y})}*4@_>ls5E5%Aw6okFQQ=ItXhzI%81>gvQ*Eg{%GH;%~lmB7V6Q4Ul- zi<5TByPF-6q3XqzCtr5pZWj=hCMSBRFU`{Q2PTK+REvlYHu8At z9Az$ULAl8f>XnH=3pKrM14E+DiY?S8pKu~;^$W~#*>@gPJH->9Y^HCBX$j%SVt4QO zRk>CJ4)u#qM!%44+=onyIDJH&{F5fx@J8iF`%Dz_yGJqss<}~Ehv|2085dSu%_PgG zwgGjj6(yAa4s6KfKQggDBU`H%$;7@Ewok|T`d7@6yle}KhwPs}tLwHh(e^bpMjJw( zSalRk(xFmZz+VM`5-{^9+K!Znmjz}JcyFEVbg2*qd3QY{{Yp8&#IZ9mfNiJ1WFB|6 z7i}fD`Tno)bT0H{6G_CjW~R=_aa`HslkJ+S6x|}1!EIA6*)9Cr4zc=fXQDuf;bpd@ z^HobpvQN|rGqT04lKpYH@F?MTE?U;woK*EE?_uGdLBDKmu0YCF1Y$_>5~{(0D15Dmm%C$J0Cr- z3?pPN>9Jy^7rce;(Uad}5#_FfvV>ba%OzAsA!j%S9~9eO(m;hO12BR@Q=Srk@oZVK zX+=Bz%sT~I)hcEVo+yI@0mft5?b$X`|9j(b33(mKLo-e92hloeho+g1JxUHS6XV%j zOUE>XP-fMDNAlqDcoDktM7i?wR0M+P&C+e3oY?lNidXEg4p?Kl9Bu}R=ZXGf0o83bw)HZjDzl%pZ;KPIseP*Xcq}4OqB{3#6 zv5KZ=WkxBHfuKW^>Cgu#wNV2XE$t_woR7;|KVNklIx_%#seMR+hJ!Q?s+Y8vCpkF4 zHkBRjHJ}Pw2Skz1V!S#j13G&=N-XLgA~|rJeA-Q>Y&M2>S=|t9l3~_|QauvKX{5`ru>bE3=fS$d!=F z^Wsj~aA2i{R^UkS+a%XePN9;Zkj?nvP-AIs_S1wZvXDWzje%uYG&Dh%ZPSpl^}|eI z*#7ITKfV$&P1s>`dGcX~-FqLSm|tncdv5Dm=%kfE|9ih0>o^Z$aEFNW@v!BIgWRhi z=F|JIl1jFmZPuwkO{SkLVYuiYN(@^+In;L8zG_v9ulm#{Jc<;#P2M$q%Dees5$R*m zoiea65^i_LXH>GfiL1#cT@L~KqXx7>@v@g>)aqif_L1;0WDOM=nK$t*;HvbzEb?+* z^ZDksB=gYar|F}Vz&$s!DYJiuQTjyCrYP)LDgVc!;A4wIhu*5F=qa%#Lc`<^6o%_F zIQwUmDu0L{8qQV!g9ZnQZY#ge!?RUN;>kV9kB9 zu=y9%?bMx?cQtjS?`@cPZ|o#Em1J2SK=Lz3laPl2WpeVbpVmVhBp8T)tdPa2(Th)^ z_K&aHli$qOjiZJw=bIv5oFfQ-gQzvw6>i`B@jsj(Z=bPgA18(bZCCJnJM|5;akg+` zbLWjoT-3D4}e%s7}im!*90i%&mljp=; z9$tMb5=OEyp!aZu$l{Lh(yL7Ad%l5dwFre09`b!~Fyk%frOif|=g1m6%$|tZ^zY}+ zL)t*tHP;dpCgZ*#`WC;oX>7mKbSoSJ6c@71P1CP7Tpl0IuRMXv-nsn|{^$U_&PLqw z+EaIgXvs5}RZh%4Jr*CSDt1$_7G~Jh79iUcPc&yJn#Oe-)PoB5_mI-B!5I0<^G$wcTURsHthk2-hAEnu=jkjK=3z#`%k>& zTk>?{kC?GFILEKaw+!BW&=Fw^igMF!diDtZDNaW!nP>xwcWl$OdBPoV*;84Hw$Et;aeyX}8nr(hW7RM4PGhp` z;7?PG-mOL{;;$*}w9-UzQxj%a$;ti@7$>3 ztOHl4ehl(KxA_yjr6z3bQoy0k<9Vc!|LD2MYBs%nXKy3=avuV&`REn@+|)R6n684h zYjbJ#<>-LQo=5j$;-)i+z>g#HDD1cRaN(+>fH=5>M4IiBo8r9B z0WukMg1QCVBulou4LaO2;AisA==hLQPtCNL)<@1NxFmL?Wyf)n< zO7tsE2=d|Yrg5XB5{@+nfKRY?6&%)#)U_3Imk82Z2Ip1Xhs<+k(bh_x+5%x$mP^SB zb3o%sAJ}z$sw&RVd%;^e`@E`*H0K-+Ah+nStxIF4KC=a$YAtl@|KXsMbME=W3Pa)@ zf=*g3E=#+^IW(UIPE~cZ6s_ANXCU>;i2HkF1lA*HE|W2abxbP{TD1p-Ml-Cgv|b-R zCyOwhV%#?Im$|xi|LSU@ve;^sT`gUPvXuKUjiRK#TY$yXhCMI=jrE4wmRx{P+iIt$ z-m~30#D47L8f6gUP&K(1D?9r-cL^+BKx1ih$)Q3zkwBu68n_bRjHFZfpWu{uDNb*q zxxT9r0Wt419eNldRS%InpAT2#Zs$4*BT#bW(&m_Bo#bJW+&5x;mU7w?2rS|pc-x#M zQ9Ztzq-X2ZZyUN{C+W8+Iov}dM`QDc^sLyojXesaA^s>4&4z7V0DM+$mLl{h21%qR zJr$BD!lr9uks8U&w^8nfsCs)v&Q2O8z>VPL{q_}F_coQZ0A?j|g)z=grv`dj_zpybqv}vk6p8(M0D}va(;ua| zx#Y*G6SNPVN44(wCBn+gbsv0prL_s7Kf4upC^F9Kz^jfUQD7J#U5^`gn4tbfUYbnB zm&l^rCdBr(*@Wsd#3Rg=&mym4Bhh$xt^de zc`Juj89!3pbZhjs*}>@ZnbpL9iM?9|v9w-KQB#Td+@iR>KC^Ft=nJ!?nE`Vu!bC@z zOD2YNqebzA_9pjc!o0U!_oT4e(~5Mv8s4Z*Civh61=kzsj*= zo(^E}aWIUvmO}D_BLm(6gM^W5>M2>Uf#?q4%ZeQY>7Abx6~ zvKo#FT$uk9C3P?_!3-VG-)p{}WWv=`*lA%P>GWEnIke?jHqE#;ygef4OdT&R9`rET zpByWrdsc}&<0FWF-6$PEnczkG&?iP=kdy=e+X>%$WIkZK`V`r8i5KwTTZ>O^a-Fi6 zBG!t{BH35CAJ{OwASh7b+>+zP=o;ZWBqjsvU|Nq^-oS1h|5h&BcPNVW@V7A4@B8|h z3!P*k8?^+T!V7IOsHY=F&e6eoZPnW^#!UgPnh52@d}*n3X^0VO!AOAAxt6ZsiGSQx zb^C6%Pm<^Ssb_P6(rKBSzY_ms-Usp)7W$!47~f7 zlKxGncef@Z^BIVF)5u8-KSL&%Bs6GNbf(a?sF>=XLC)ur3v4mIi!dVbq9h!8VFsU} z>9HTdOGaBXe1t6qxrN+=4W8~CQvOw^<&bOUl>VS$=d`j1wcvhgv~j>Zx$Mv64K@xu zj-DNzNeIMM&HPcTmFe0mfX^v;JsGK{&l$#?d!;+nEsjI{4b)qbe6nrs;pm5jRfk6?u4c8NMrS-)OBW%^UF}+ygg* z8&xaZZZEp2l)iIyL=3{0Kr_u|o7wr*>cEh>Hg5nk^0CJgB}%fMcFZ`sECHJ0D`gx+ zXhaLXp8PmOV+*N}#k#$D=rWa{vKkX9px{6f6VzYk4z1tZV9_0$nk^uR^l3k|;A42@ z{(0UPATOR7$_|l*9Gf%sY-}CFl+CFLu0Em+L|i7+gBC$D!S7HfeN5ZEvaM^c-{$R% zt}TC#xw!0p(_e3pSU$_;z}jjN2#+9NfddR;Sr=b*BKR0^0)8QtBdO^+7vv*Sy6e<7OIuq!MjvXA;~FrGz5)Y z_#iv=7fF2#s!dVsihb)$+nbP!daYh_5MCLmv@x7iW_J$9R=Cz(VpiptT}+X<$4jY= z8BrVfxy91z!i(&HG=;W`+TQf?G^=}BAZ+eo~U1xNLbO9-{?T+uC-1+ z%<`;aCI_%9UP`PNMR8wxtXmeZB`9srzCplUeO2&ofJ|LRNFpmr;N)|UHN`Pq=gJf7 zOX+TZj8~W)PsY>wzXK8RW}yR}(PZwY*JqB3c?B{dvir9HY!R+HVOYZWb0>M&|Na+! zi2!+1PY{}TB*ZzPm#kR49_(}HZt*Am|w4K2I=HS}?pcV|=SK1iWUwo@mK9l^}8xYehKn1Y@n^Ya~jX0mZiNr3BM zmG9UpV>D|7gwksrX>TWqK&$i8FXuw#Nz+zK)AERl3WUYjxQIA`Pi58Y*}zP>Z|t1S zJH`pg_J1&0Z|&=8chR{0)3buwpO6XZTK}0}FKI!w1zI(*H%oM~Z&K;F^k0wgY@ot4ULULff}KpiIZIFgQDGsQbs;X|6O?$*Vph zxugd?x*~t0hzB=G$#lYz*z4XSy0usT;Sk(DdcIsh>_xp#nOu`5>mI1XU_0sreuca= z9Gd}ZY^qi|cnKACgf2~CPr{|f?hBqr$FGt6=y4PuWPgYDXUtY?6F4>WlJ2p*t<+2$PPeOQ@ zeW}#@jT)P?keO8ekmaf(H~eMbNLX-x?$2kCoeD!6WVm3HyJp-e8#ILRPJq9IhpFE! zw2(i~wffm~QT7Glz-%~7UOJ6J&E)&5ekgzeC?wJhsW(6YP*7Xm^T1f=-zovy|Tb}L(-q5 z8Im~p?3{{Bc)V1wGuE@FCkGEcBU#{^uBfhWwh95Fc3By|s-eevntUp=&D%dRP54d_ z8wqj45Xv+;#DMJKTapB4Kd`v6{KQYm11aG0*b^0?Oub4z9d?=*SN}2nx1Z}N^Zcg8s<1#I#6@< z>|$7YVivt0%0PVdyky%rC3HA3c*@xz$gp=CAnBWRnL8{J>*UosHgERB^2DU0$xLGw zjUF?Y^f8S7hf`je(^#gL6O+~%9eAHX31{bR$3wcV@Fk_jkrx=*`>*a7d(H+1GYukYv7#xKsJ=%)nV5OcI-w z!vpGu4k+gf&xyQIkqH9=czr}e&39oXEI&0XcNmH(vn9Z!ti;SSuL?@4Ugsyb3|tJ$ z>x^aq@+hNd0XIXgWE3L->z=3$nk$-inFZvo|i^a+3V>h_P9Z<<(kn}w=n>t-*%s_*2esR z9d#yJCqGD8O8SeCHAe+jVrV3v|D4u0KkVLaI&z zct+>X)Q=22J1>0ltdZqPhED{yk6{>KPia-Z=fwB8Hk!3B_yzk?Og+df)C-uAk+nSb zodDR#Nv$iIiJ^DPkbN(Br;r~dH(Ya!-8JDPAf!lSoEFr|GB%3k*(1O!ib9A84}5k7{ANWvs|lbfg)xUwe0(t&&Z48?!YV0ujIAw_kFY(F~yy zIx>4kcKwgFuXNPuOH8O#$a%^IUiUc^=lp(qB0W{BO3bAM2a-3_#jh10xF2LQ95|11 z{V*0hi~VV@hvYHH^_uT1DGx{ZiN7yQQV5S%sC*M{@83Nu)f!HgWZ;)VOwIbL4v1n= zmGPRMp3wOWt;#y-jRFIg=m3}~0{Gp;=5a1eh`m_%Mvu6e2YDj0i|dp`wF0klVlZl& zJOhaDyB5kui6UviPADOd9hYHO?*^pJ;3y6tYdvI*1@c zX-7|TqVeXTa0Vk-&iiMB0^joId!s9`E}8osr&k8pIYc-uPa?qhY1;6LoY0&?&)ide zC8K&Zn2K0bY=pS&G?8fu1~XSE3U4B*E)RdSC3w+)C(6FIX>X-Jdt>4#r*+m@0Ma~88> zX<&+Mo~tvvFEOS^(KG9;Sw6?r?Ue*m9@OErZ@-;P5faU(kgv0y%to}`xt}U*;(i39 zLxZ=qd)&q?EB1>Z%dW2q=-!R~_vco-#wAivRR(H7!T{$AbK}+0@P8@xNH_@ZaE&*am|6YhusyZEnh~SGY zwuDy4-&@*YN)-{iQ`GX5VfMCF3pwS_--VeFfRAcq3-jabf78k~3vFk>>D{|f&xbcMzqD%tre<~%?>8`!|ad|0Gj-h!cDaDN{`LJ9k<+H zVmMWKYoesBM<&U>Uwjoh7BWR#Qld59I(*y&~lZz3zoBPhAm#};W++?o$RpL zyQ(E^uec85ZX+X?!>MzdorPPt2-=#)4#NYG=YT_Wv{3e$wduEx&DgM_O+Jm$tT@x7e~%0dg<5gdkc%s~wiT zn#82&qP;Mbr#pRGbM9|5AB?`%Lb!o95f(M;sRm-LrHNf+DGLj_$e;W1aRxzo`XI!J zs;^0bC`$cNx?U60rNT+y z%8APs#PnY_FZ?7PHqNx&q`97yp4}pU#uHrXlrb{)sDiiBK8!-x5kz8l8wK*;| zSzJS?FP`cL?w}qvUR|0h=?A!fLyX9urlz(7cy~Z)J?QF%y(d#YG#ZNWp*%i_urT#) zPQ3~-HW#Nd4&Eq$@G+`9-Rj>VlF>Hs=RtTBJ#k_p?njjmc;}u-kq3A9imAB|Jy`p| z`g_WX!UqPp(?d!ro@BSdM6Jf1D%C0^2HJ$yzYo0?%8!}Y*H{>YR5aWhb=$#VmPKNp z9217N)cYjwGw6Fd24;ul@NsZ9PnW+x70UTg5!_#AoN}D0$Ap;lN^ZIcD^ZaLI1fgz z1rkd(O+3=6sF(m+X`vUBN_ay7|KiTU^#mQ8pN?s{Y(@@5(aZY1qiO-3JI)|BpTF-B z>(cJvs6hKg39sIQnxHQcw$M%uZ>Cm2w9xDw+fEC|$t=OHBJP}hCnxt0$H#4Zl48Q1 z=>Mzg%HyH#zW+#NCo&;xjbcV98QDiY*_W{mVl3Y%nd}}CF`_3WOR|-1%08BCV@bA5 z_N44F*(LkF&KSR|-}jH_`{#UKuX(@jJ?p*a+;h%~fyk}kEYpAxsZ%;}=^$3dQP+xY zd42tOm=WqhSi~v$;|bJ8!gZrPe}{mx=r_i3ja`!Gv~1QyzrHyZSIT;@xMrF+>z%IA zjpPmr_u_r>>Qj(X&WZP)mTD>lz2^@f#k4Nyy(?jZGma!{8AOMx3yFPdKn6&7&anjQ~Q3mX*i9G&|6-@PB7HFfhKoP485L49E_P*SUNgw3OAcyM@)m*$nDFbv;0h>MW@>J;d||E;2?;B zV{VKKIR)WHZM~UiiAZiq@&d?=y@N7V{dC+7Fs&(eSMK`-(Yg}1u$hyk!|&5dEIQ9_ z)ybJw-K_gKw$;w%xRR;}!ZwTj^h(#gH|+PM+f}C`ydgiN39G~#%0%%^{`M6kj7MEh zg(deLsQKwm=h6ahhjCd{M}d!s6??IVbu7PBh!(>(c;Y>bye-}EOKDcA;4$nHM2!iV z#_#x?xehqUSW_fM?0x2-s&CglL=xF=6QF0wO&;?+2(Cz9L!`4DXQ%s?mX3t_Z|0~%Lirt` z8BUZ=@H$$Vu#Wh0KU7e$ZC2I1h`~U}SAFw1WOG&+W!)opTIRD*qzJyd4ja{|;unIe zt3Syd9XMHZ{+&mPK#_Umu=GtEAxMArDU9v3+tMwZwcO<&up(^q_~zCD9MqQCNr)>7 znTbaT$mNEagu{N>X-V3a@qqrNGR}A9VQ|IPZ)TWpvZjb3cFrrQJ9!Z!WiwCJ=HsB> zKEX{&)|DM;I5?kY(|C|_eesY zzrDay<_fWMlq*#NMNu--EEpTHox*N-lDG)`_dCz2P{o{|&5mQWNNC-!<3*{1-$hQX zW{l`0CI6j`_DYwUJYc-_iiJUlVYEJe)jqPe=sCP`s^}sAuvz&GvwV8)h@H?bVN&*Z zH*}hyqWNtKM&YJ#e_{%inpue zBQ(T87Ct_>^~*lU0X=c-Tnm2+T8Q;p(%|IrW}<)x->wI0YrkQ_UM(o}5JL9{#g>l< ztC{}>L%E2~h?WI)XP_nwhYOti8_4+Gvkem#F-=l8vhfo#t7BFBe>Sn_Z{`}3k#;6BviQX#Bw?R^L{Gkd zL!2wE7k2o=5&aR_8PW2f?)xd@kxTUAX1V|*W{)mPGrWVPF@AW%Y|rnHJq?OY|A@w` z6v0KuHvAu!T>Up5(uSI`js6h3=G-{E(}Wh9M{(9_UgG1cxu36pp`9nLYCI z(m?HNN{Lxq$*=m%gYu%{@Fm#fT^-v}aWz1+bZ)9;d)1kmXv#sl!2#EXCv~7EQ-$mN|BFSCFIAGp;Bj`r^pVC|+DE*@?{)T@<1y+}E0^%g z#iMvv&Mmow#E3-~(VM|v#!gJ_x{SNjpWm&QmA%!!)JD=0Mb?!^+c3S{;7n`6*V)TH za_d`)+Dsg{vZ*-!cky@p+1-d`y1>tbpDEwb>#bmL8R*k1k>P%(fZOo@`=n5U#pIve5Tt%gC zD^-FfHH|-Qm_J4lvgwSYAITS$CZ2lm%b(|;v}nKewXEBM3lSMftmDVLHo;w}-1p*w zhpgx#VKt{S2M1do(P8o-P3>9crH5AC$kbt>aB9q9(zZ`!u0q{NUAoWjRK<(npY*}d zef^c~9*dWxj!F@|<~n z(DzkiA)@%wP|%ml2uh{(W5Fjpg#6e8#K)oeNcBJ$PbORNPi*3#%$0SiaMll9CVpdy z65!hHwfXPk>b5}$ASdJL> zReO}{$F=RDcg^pTO+mayJLzb_h1ufPnkOInvxUW)$QOQ|T?qh+v-s11BZCDG6c5Ta zS3?xE9=1ap=hCFEiCb$qcFdFAG@KMiX?|a&w5eCkIbJVKcdNP~*^@2uj10k8h9I() zp50xdz!FRZp#TcE3t&u&1!Ud6gy&~^Uds8K(V=#R*UU91d$8MkXmm%pw>0dcwi8Oq zF*R7eUFdP$)2jpT>yz@!XWVkSudgS&CcEdIRPUBW0VckGaDmc){)N;rFvC=L=;e)w z-XK|HRq5L5BM#@COvqQbW}^?{z;tMQ)-BB-$0-kETxzbzDE~_H{r4%vS(J0U<3Si5 z%3gCf&+_CjR%nd`q|s3UYfwwuRbnj3o4#%7vS1yi+t)!G=vh^E^H|Mu>aD;Y8|XQ> zk=$ZxLFtz^uw>xs@MgMh(8%97rPiSLjo#Uh{wkdf44e@vfh`*sF@_{%q83G@(ny#$ z>00~=u3YFM7}QlfHN7G2o=ghyKc#L8+WDfZdC^J4^u(T+U6wo#qAu@AzeM{S$=f~~ z#ll{e4dqC+kN?zGA3~1w1?H0cZqKl=(f2Ev4oz{q$`4n>ZhVz|V9`{qw9ss)Y*$D@ zlB$I}())^XaO9QY&Q_{49m*q+tnu~wRBn}g1gM}Y{IpYaItS-0z3svbP!jH2t zf8CeF9zwkGZQueUo<0KIs9bBdGlDhSvl~TRi*-7Z&4q8bsJS$Fu3q21G$~U(Vr;_9 zD_-4`Afd;`i&EUHu}Ip!oAH^!O|n}IikdK~j3Xvu6xQjifpp%GjE^rII5iDHk?mVB zhoeA07Z=t2V`jFpcW|AED)+^|NglRC4WTB?li38`IwXKr^ElfMxz^2s=YWb3s(iHs zHFtH%6n|&_sanIG`1CP2swerv8_J12n|Yk3c-roQrHy{ydc*Is)Brfp#6&d-vlH0i z&KQmGwI%cC@QIRRA{@Kml(`|O~1jfeg6uGS1=MYqwS*$}gqD{+snGj<}37^Whs<-3ey zWEa;{qc4Y7O=$F1nD1E+m}&=sA4cQG2u!uaey>Iwg&fu`vK2%sGSV6oCjFP4O7dwK zoAbk1e2w;$4r`#&LW~F2>{J8Aof0Bt;F28vMUzzD`kx$*tLE;BJ z+Ca>+J*@K9=2n4zHZH2ern8suvM*P!|AvE2XV}<%GYyQU{X|v@otHiw6`sRxSjKSAeqet!if62??o=o<>f%x z)B%~!>vQ7SvE{FV>5X36UuelC@?AQz3O&=Sa=BoCXDx^!W6nwRJ&)CVLzWEQuV09J z#N`y`4W@5Y3}LYg9kc0Td8DvJqm+EcE)uAfiw9zSFPS@)ST#L+)IMMAD`I}m`km5c z{m88$JZGOwTmf8VDI0dsO9!eQn#|aDdW(p8tT=y#mQM*VB8DEB7Oo={8(Ud3782mH zFKWU@^VOHfujC?qR_4&{*lT9<_-^a(ZW5-A&7==N$iEF6Widy-?d=2u$qoTtKYqNC#)Y5^BQQM}VXY8EWK2SbB3>Iakgn zj~Mg|(YF5x&pvC}zFiI<{>RLpwSq*nCI;a}N+^0}!xr62%-7AvN$t2z!*Ub|#o71R zgiU#|Bl@XeF<^dr%3rTTK0XlDd!{pVp)>|pa;f5s5>@`m4zS9m6;HE_R?2m|k9?f| z8&wFGz|eW!OY&wOob8}hFAXAb zd@2f6BGq~4d$(XspWv|i-iQj}=o}@gYPG7f3(yc!`#s})BfoK~*-sq?eoXadonicK z7G||zfaK&-lQ%+-kx}y_0ImvmeDO^+uoki*Or|9)n->HfdZs|3-m_j@^ zDt!kwc!b%n%QFzFsW4IXK+F?iY*jhwt(~n=6P)oR&P`HHeQse7)3(`l^Ih!Hk%>g_ zVu+_Ebt20`ug|s0w{rA{yY_t_n!jY^He;V(wTp?(o;S!A88vA=a(iGVyQ3nn>NT^m zfegt9i?CR0Qy1&d((^0|G3KZVv@OcA1-K^E57dOVoIq@-a$GAIv?#M`^J~G{d+uxj z$DGGSECAyZ-IMu2f6reZLF{aEUJ+b35>j zn7l<_Bw3q1lYR{66rb=obN=A8ZE~R6Ku>(^{Trxf>G0m!Qxx}DCc(ObUuo(apB1Q& m@^Y9CdhN=`4}zBio5L(BW8>G-fXD1A3fEQWZz=XU{PBO5%?nEa literal 0 HcmV?d00001 From dd292474c830fa54ffeae39a883b0092ba908760 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 09:01:31 -0700 Subject: [PATCH 09/17] dasGlsl: emit a real for-header so `continue` advances the loop The GLSL emitter lowered `for (i in range(a, b))` to a while() whose increment was appended as the last statement of the BODY. A `continue` in that body jumps to the condition and skips the increment, so the loop never advances: it compiles clean, links clean, and then spins the GPU until the driver kills the context. On macOS that surfaces as "Caused GPU Hang Error" with no line of diagnostic pointing at the shader. The increments now ride in the loop header, where `continue` reaches them; a multi-source for puts all of them there, comma-separated. Verified in the emitted text, not the das source: nested range loops and the shadow-map PCF loops re-emit as `for ( ; i!=_for_range_vi.y; i++ )`, and River Run's SSAO pass -- which is what found this -- now runs its natural `continue` without hanging. The other two emitters are already correct and needed no change: dasMetal emits `for (T i = lo; i < hi; ++i)`, and dasSpirv branches `continue` to a structured loop's continue block, which is where its increment lives. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/rr_postfx.das | 19 ++++++++----------- modules/dasGlsl/glsl/glsl_internal.das | 23 ++++++++++++++++------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/examples/games/river_run/rr_postfx.das b/examples/games/river_run/rr_postfx.das index 099bcf1804..b809191369 100644 --- a/examples/games/river_run/rr_postfx.das +++ b/examples/games/river_run/rr_postfx.das @@ -171,17 +171,14 @@ def fs_ssao { let clip = p_params2.w let su = float2(sample_pos.x / (p_params2.x * -sample_pos.z), sample_pos.y / (p_params2.y * -sample_pos.z)) let suv = su * 0.5 + float2(0.5) - // Guarded rather than `continue`d on purpose: the GLSL emitter lowers a - // range for-loop to a while() whose increment is the last statement of - // the body, so a `continue` skips the increment and spins forever. - let inside = (suv.x >= 0.0 && suv.x <= 1.0 && suv.y >= 0.0 && suv.y <= 1.0) - if (inside) { - let sample_depth = texture(p_tex0, suv).w * p_params2.z - let delta = -sample_pos.z - sample_depth - let range_check = saturate(radius / max(abs(-origin.z - sample_depth), 0.0001)) - if (delta > 0.02 * clip) { - occlusion += range_check - } + if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) { + continue + } + let sample_depth = texture(p_tex0, suv).w * p_params2.z + let delta = -sample_pos.z - sample_depth + let range_check = saturate(radius / max(abs(-origin.z - sample_depth), 0.0001)) + if (delta > 0.02 * clip) { + occlusion += range_check } } let ao = saturate(1.0 - occlusion / float(taps) * p_params.y) diff --git a/modules/dasGlsl/glsl/glsl_internal.das b/modules/dasGlsl/glsl/glsl_internal.das index 10bb02a8f6..4217a6cefa 100644 --- a/modules/dasGlsl/glsl/glsl_internal.das +++ b/modules/dasGlsl/glsl/glsl_internal.das @@ -1654,9 +1654,15 @@ class GlslExport : AstVisitor { } return source } + // A real `for ( ; cond ; i++ )`, NOT a while() with the increment appended to + // the body. Under the while() shape a `continue` in the body jumps straight + // to the condition and skips the increment, so the loop never advances -- + // which compiles clean and then hangs the GPU with no diagnostic. The + // increments therefore belong in the loop header, where `continue` reaches + // them; a multi-source for puts all of them there, comma-separated. def override preVisitExprForBody(expr : ExprFor?) { newLine(1) - *writer |> write("{repeat("\t",tab)}while ( ") + *writer |> write("{repeat("\t",tab)}for ( ; ") for (source, svar, iter in expr.sources, expr.iteratorVariables, expr.iterators) { if (iter != expr.iterators[0]) { *writer |> write(" && ") @@ -1671,6 +1677,13 @@ class GlslExport : AstVisitor { *writer |> write("{svar.name}!=_for_range_v{svar.name}.y") } } + *writer |> write("; ") + for (svar, iter in expr.iteratorVariables, expr.iterators) { + if (iter != expr.iterators[0]) { + *writer |> write(", ") + } + *writer |> write("{svar.name}++") + } *writer |> write(" )") newLine(1) *writer |> write("{repeat("\t",tab)}\{") @@ -1678,16 +1691,12 @@ class GlslExport : AstVisitor { *writer |> write("{repeat("\t",tab-1)}") } def override visitExprFor(expr : ExprFor?) : ExpressionPtr { - for (svar, source in expr.iteratorVariables, expr.sources) { - *writer |> write("{repeat("\t",tab)}") + for (source in expr.sources) { if (isDimFor(source)) { renames |> pop() - *writer |> write("{svar.name}++;") - } elif (isRangeFor(source)) { - *writer |> write("{svar.name}++;") } - newLine(1) } + newLine(1) *writer |> write("{repeat("\t",tab)}\}\}") return expr } From 3ea20fdad9fb0838c57414f44ded55587e1841a7 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 09:05:31 -0700 Subject: [PATCH 10/17] River Run: apply sun-shaft strength as intensity, not filter radius External review round, verified against the tree. add_shafts_to_bloom passed cfg.shaft_strength through fs_up's p_params.x, which scales the SAMPLING OFFSET -- so the per-section shaft setting was changing the blur width and never the brightness, and a strength of zero would still have added the shaft buffer at full intensity. fs_up now takes radius in p_params.x and an output multiplier in p_params.y. The bloom upsample chain passes 1.0 for the multiplier and keeps its radius; the shaft composite passes a fixed radius and the strength as intensity, which is what the five section environments were always trying to set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/rr_postfx.das | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/games/river_run/rr_postfx.das b/examples/games/river_run/rr_postfx.das index b809191369..cc2a41eac0 100644 --- a/examples/games/river_run/rr_postfx.das +++ b/examples/games/river_run/rr_postfx.das @@ -117,6 +117,9 @@ def fs_down { // --- Tent upsample, additively blended onto the next larger level +// p_params.x is the filter RADIUS, p_params.y the output intensity. They have to +// stay separate: folding intensity into the radius makes a strength of zero a +// zero-width tap that still contributes at full brightness. [fragment_program] def fs_up { let t = p_texel * p_params.x @@ -129,7 +132,7 @@ def fs_up { sum += texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz sum += texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz sum += texture(p_tex0, p_uv + float2(t.x, t.y)).xyz - p_FragColor = float4(sum * (1.0 / 16.0), 1.0) + p_FragColor = float4(sum * (1.0 / 16.0) * p_params.y, 1.0) } // --- Ambient occlusion @@ -650,7 +653,7 @@ def private run_bloom(cfg : PostSettings) { bind_target(bloom_fbo[i - 1], bloom_w[i - 1], bloom_h[i - 1]) p_tex0 := bloom_tex[i] p_texel = float2(1.0 / float(bloom_w[i]), 1.0 / float(bloom_h[i])) - p_params = float4(1.35, 0.0, 0.0, 0.0) + p_params = float4(1.35, 1.0, 0.0, 0.0) vs_post_bind_uniform(up_program) fs_up_bind_uniform(up_program) fullscreen_pass() @@ -689,7 +692,7 @@ def private add_shafts_to_bloom(cfg : PostSettings) { bind_target(bloom_fbo[0], bloom_w[0], bloom_h[0]) p_tex0 := shaft_tex p_texel = float2(4.0 / float(fx_width), 4.0 / float(fx_height)) - p_params = float4(cfg.shaft_strength * 1.6, 0.0, 0.0, 0.0) + p_params = float4(1.2, cfg.shaft_strength * 1.6, 0.0, 0.0) vs_post_bind_uniform(up_program) fs_up_bind_uniform(up_program) fullscreen_pass() From 7cfd794f37aa1fd90fc840d5de4755342380c67e Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 09:24:49 -0700 Subject: [PATCH 11/17] River Run: ship the playground port, so Safari and iOS get it too Same model as arcanoid. The playground interpreter cannot take the live/* + live_host stack -- static requires cannot be swapped at runtime -- so the port is a copy under web/examples/ui/samples with live_stub in their place. That is the one irreducible reason these ports exist as forks rather than as a flag. Four edits separate the copy from the real sources, all of them at the seam: rr_globals swaps ten live/* requires for one live_stub; rr_postfx drops live_host and live_vars, which leaves `@live` as inert metadata (there is no reload here to persist across); main drops rr_live, the REST command surface, which is a development tool with no role in a browser; and rr_audio gates music on is_standalone_exe() as well as the threading check. That last one is the music-and-threads question: the playground shares the renderer's runtime and cannot host the threaded strudel worker safely, and it does not report a single-threaded audio backend, so the threading check alone would not catch it. SFX carry the soundtrack there, as they do in the compiled wasm build. The card drops wasm64Only and gains a playground link; the sample is registered in data.json and in _interp.html's file list; pages.yml stages river_run's sources and HUD font alongside the other three games instead of as a wasm64-only card. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- .github/workflows/pages.yml | 19 +- site/examples/_interp.html | 1 + site/files/examples.js | 5 - web/examples/ui/samples/data.json | 5 + .../samples/examples/river_run/gameplay.das | 2155 +++++++++++++++++ .../ui/samples/examples/river_run/hud.das | 191 ++ .../ui/samples/examples/river_run/hud3d.das | 328 +++ .../samples/examples/river_run/live_stub.das | 137 ++ .../ui/samples/examples/river_run/main.das | 392 +++ .../ui/samples/examples/river_run/river.das | 641 +++++ .../samples/examples/river_run/rr_audio.das | 566 +++++ .../samples/examples/river_run/rr_globals.das | 631 +++++ .../samples/examples/river_run/rr_models.das | 547 +++++ .../samples/examples/river_run/rr_postfx.das | 743 ++++++ .../samples/examples/river_run/rr_shaders.das | 682 ++++++ 15 files changed, 7020 insertions(+), 23 deletions(-) create mode 100644 web/examples/ui/samples/examples/river_run/gameplay.das create mode 100644 web/examples/ui/samples/examples/river_run/hud.das create mode 100644 web/examples/ui/samples/examples/river_run/hud3d.das create mode 100644 web/examples/ui/samples/examples/river_run/live_stub.das create mode 100644 web/examples/ui/samples/examples/river_run/main.das create mode 100644 web/examples/ui/samples/examples/river_run/river.das create mode 100644 web/examples/ui/samples/examples/river_run/rr_audio.das create mode 100644 web/examples/ui/samples/examples/river_run/rr_globals.das create mode 100644 web/examples/ui/samples/examples/river_run/rr_models.das create mode 100644 web/examples/ui/samples/examples/river_run/rr_postfx.das create mode 100644 web/examples/ui/samples/examples/river_run/rr_shaders.das diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1270bb53f5..1d6f69304a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -488,7 +488,7 @@ jobs: # cp -r site/files. mkdir -p _site/examples cp site/examples/_interp.html _site/examples/_interp.html - for g in arcanoid pacman boulder-dash; do + for g in arcanoid pacman boulder-dash river_run; do mkdir -p "_site/examples/$g" if [ -f "web/output64/examples/$g/$g.wasm" ]; then cp "web/output64/examples/$g/$g.html" "_site/examples/$g/" @@ -504,23 +504,6 @@ jobs: cp modules/dasStbImage/fonts/droidsansmono.ttf "_site/examples/$g/" done - # river_run — the shadow-mapped river shooter. wasm64-only: its render path - # (deferred-lit pass + SSAO/bloom/sun-shaft chain) needs the compiled build - # to hold frame rate, so there is no interpreted fallback and no .das - # sources to stage. Same placeholder rule as the showcases below: the card - # is always listed, so an incomplete build must not 404 the iframe. - mkdir -p _site/examples/river_run - if [ -f web/output64/examples/river_run/river_run.html ] \ - && [ -f web/output64/examples/river_run/river_run.js ] \ - && [ -f web/output64/examples/river_run/river_run.wasm ]; then - cp web/output64/examples/river_run/river_run.html _site/examples/river_run/ - cp web/output64/examples/river_run/river_run.js _site/examples/river_run/ - cp web/output64/examples/river_run/river_run.wasm _site/examples/river_run/ - else - echo "WARNING: river_run wasm build incomplete — staging a placeholder so the always-listed card doesn't 404." - printf '%s' 'River Run — building

This example is being rebuilt and will be available shortly.

' > _site/examples/river_run/river_run.html - fi - # furier — the ImGui-in-wasm showcase. wasm64-only: it bundles the compiled # dasImgui module, which the universal interpreter can't bind, so there is NO # interpreted fallback (no .das sources to stage). The card is always listed diff --git a/site/examples/_interp.html b/site/examples/_interp.html index 7ee4539802..d2a21fcc30 100644 --- a/site/examples/_interp.html +++ b/site/examples/_interp.html @@ -31,6 +31,7 @@ // games not listed here ship the default two-file bundle. var GAME_FILES = { 'boulder-dash': ['main.das', 'cave.das', 'cave_gen.das', 'sfx_gen.das', 'live_stub.das'], + 'river_run': ['main.das', 'rr_globals.das', 'rr_shaders.das', 'rr_models.das', 'rr_postfx.das', 'river.das', 'rr_audio.das', 'gameplay.das', 'hud.das', 'hud3d.das', 'live_stub.das'], }; var loadEl = document.getElementById('ex-load'); function note(s) { if (loadEl) loadEl.textContent = s; } diff --git a/site/files/examples.js b/site/files/examples.js index 1b26a40184..00ba7b85f1 100644 --- a/site/files/examples.js +++ b/site/files/examples.js @@ -54,11 +54,6 @@ controls: 'arrows / A D steer · W S throttle · space fire · esc pause', poster: 'files/examples/river_run-poster.jpg', aspect: 1280 / 720, - // The deferred-lit pass plus the screen-space chain (SSAO, bloom, - // sun shafts, FXAA) needs the compiled build to hold frame rate, so - // this one is memory64-only like the other heavy cards and ships no - // interpreted fallback. - wasm64Only: true, }, { id: 'furier', name: 'Fourier Series', kind: 'imgui showcase', diff --git a/web/examples/ui/samples/data.json b/web/examples/ui/samples/data.json index 0a6be22cbe..aebc68aee4 100644 --- a/web/examples/ui/samples/data.json +++ b/web/examples/ui/samples/data.json @@ -20,6 +20,11 @@ "slug" : "boulder-dash", "files" : ["examples/boulder-dash/main.das", "examples/boulder-dash/cave.das", "examples/boulder-dash/cave_gen.das", "examples/boulder-dash/sfx_gen.das", "examples/boulder-dash/live_stub.das"] }, + { + "name" : "Game: River Run (3D, arrows + space)", + "slug" : "river_run", + "files" : ["examples/river_run/main.das", "examples/river_run/rr_globals.das", "examples/river_run/rr_shaders.das", "examples/river_run/rr_models.das", "examples/river_run/rr_postfx.das", "examples/river_run/river.das", "examples/river_run/rr_audio.das", "examples/river_run/gameplay.das", "examples/river_run/hud.das", "examples/river_run/hud3d.das", "examples/river_run/live_stub.das"] + }, { "name" : "ImGui: Fourier Series (epicycles)", "slug" : "furier", diff --git a/web/examples/ui/samples/examples/river_run/gameplay.das b/web/examples/ui/samples/examples/river_run/gameplay.das new file mode 100644 index 0000000000..4b4370d91e --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/gameplay.das @@ -0,0 +1,2155 @@ +options gen2 +options persistent_heap + +require rr_globals public +require river +require rr_audio + +// --- Spawn helpers --- + +def spawn_trail(origin, color : float3) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, Particle( + pos = origin, + vel = float3(0.0), + color = color, + lifetime = TRAIL_LIFETIME, + max_life = TRAIL_LIFETIME, + size = BULLET_SIZE * 1.1, + spin_speed = 0.0, + spin_phase = 0.0, + kind = ParticleKind.trail, + drag = 0.0, + gravity = 0.0 + )) + } +} + +def bonus_color(t : BonusType) : float3 { + return (t == BonusType.fuel ? float3(0.15, 0.9, 0.95) + : (t == BonusType.multishot ? float3(1.0, 0.45, 0.12) + : (t == BonusType.fastshot ? float3(1.0, 0.7, 0.05) + : float3(0.95, 1.0, 0.3)))) +} + +def spawn_pickup_burst(origin, color : float3) { + create_entities`Particle(14) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let elev = random_range(-0.25, 0.85) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + let speed = random_range(4.5, 8.5) + p.pos = origin + p.vel = dir * speed + p.color = color + p.lifetime = random_range(0.18, 0.32) + p.max_life = p.lifetime + p.size = random_range(0.06, 0.14) + p.spin_speed = random_range(-15.0, 15.0) + p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.ember + p.drag = 2.2 + p.gravity = 5.0 + } +} + +// --- Explosions ------------------------------------------------------------- +// +// One blast is six layers, each with its own lifetime, motion and blend mode: +// a single white flash at the instant of the hit, a handful of fireballs that +// expand and cool, embers that arc under gravity, smoke that rises and outlives +// everything, solid debris, and a flat shockwave ring across the water. The +// layering is what turns "boxes flying" into an explosion; no single layer +// carries it alone. + +def private spawn_flash(origin : float3; radius : float) { + create_entities`Particle(1) $(_eid : EntityId; _i : int; var p : Particle) { + p.pos = origin + p.vel = float3(0.0) + p.color = float3(1.0, 0.96, 0.86) + p.lifetime = 0.075 + p.max_life = p.lifetime + p.size = radius * 0.75 + p.spin_speed = 0.0 + p.spin_phase = 0.0 + p.kind = ParticleKind.flash + p.drag = 0.0 + p.gravity = 0.0 + } +} + +def private spawn_fireballs(origin, color : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let elev = random_range(-0.2, 0.9) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + p.pos = origin + dir * random_range(0.0, radius * 0.55) + p.vel = dir * random_range(0.6, 2.6) + float3(0.0, 0.0, random_range(0.4, 1.6)) + p.color = color + p.lifetime = random_range(0.26, 0.46) + p.max_life = p.lifetime + p.size = radius * random_range(0.22, 0.46) + p.spin_speed = 0.0 + p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.fire + p.drag = 3.4 + p.gravity = -1.2 // hot gas rises + } +} + +def private spawn_embers(origin, color : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let elev = random_range(0.1, 1.5) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + p.pos = origin + dir * radius * 0.3 + p.vel = dir * random_range(5.0, 15.0) * (0.6 + radius * 0.4) + p.color = color + p.lifetime = random_range(0.45, 1.15) + p.max_life = p.lifetime + p.size = random_range(0.035, 0.085) + p.spin_speed = 0.0 + p.spin_phase = 0.0 + p.kind = ParticleKind.ember + p.drag = 1.1 + p.gravity = 9.5 + } +} + +def private spawn_smoke(origin : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let dir = float3(cos(angle), sin(angle), 0.0) + p.pos = origin + dir * random_range(0.0, radius * 0.7) + float3(0.0, 0.0, random_range(0.0, radius * 0.4)) + p.vel = dir * random_range(0.4, 1.6) + float3(0.0, 0.0, random_range(1.1, 2.6)) + // Smoke takes the section's fog colour so it sits in the same air as + // the rest of the scene instead of reading as a grey decal. + p.color = lerp(float3(0.10, 0.09, 0.09), env_now.fog_color, 0.35) + p.lifetime = random_range(1.3, 2.4) + p.max_life = p.lifetime + p.size = radius * random_range(0.24, 0.46) + p.spin_speed = random_range(-1.2, 1.2) + p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.smoke + p.drag = 1.5 + p.gravity = -0.7 + } +} + +def private spawn_debris(origin, color : float3; radius : float; count : int) { + create_entities`Particle(count) $(_eid : EntityId; _i : int; var p : Particle) { + let angle = random_f() * 2.0 * PI + let elev = random_range(0.05, 1.1) + let dir = normalize(float3(cos(angle), sin(angle), elev)) + p.pos = origin + dir * radius * 0.4 + p.vel = dir * random_range(3.0, 9.0) * (0.7 + radius * 0.4) + p.color = color + p.lifetime = random_range(0.6, 1.25) + p.max_life = p.lifetime + p.size = random_range(0.035, 0.10) * (0.7 + radius * 0.4) + p.spin_speed = random_range(-13.0, 13.0) + p.spin_phase = random_f() * 2.0 * PI + p.kind = ParticleKind.debris + p.drag = 0.55 + p.gravity = 11.0 + } +} + +def private spawn_shockwave(origin : float3; radius : float) { + create_entities`Particle(1) $(_eid : EntityId; _i : int; var p : Particle) { + // Pinned just above the water so the ring reads as a surface wave + // rather than a sphere seen edge-on. + p.pos = float3(origin.x, origin.y, 0.06) + p.vel = float3(0.0) + p.color = float3(1.0, 0.92, 0.75) + p.lifetime = 0.5 + p.max_life = p.lifetime + p.size = radius * 3.0 + p.spin_speed = 0.0 + p.spin_phase = 0.0 + p.kind = ParticleKind.shock + p.drag = 0.0 + p.gravity = 0.0 + } +} + +// `scale` drives the whole blast: 1.0 is a boat, ~1.8 a fuel depot, and the +// player's own death runs at 2.2 so losing a life lands as an event. +def spawn_explosion(origin, color : float3; object_radius, scale : float) { + let r = max(object_radius, 0.35) * scale + spawn_flash(origin, r) + spawn_fireballs(origin, color, r, int(3.0 + 3.0 * scale)) + spawn_embers(origin, lerp(color, float3(1.0, 0.85, 0.45), 0.5), r, int(10.0 + 12.0 * scale)) + spawn_smoke(origin, r, int(3.0 + 4.0 * scale)) + spawn_debris(origin, color * 0.55, r, int(4.0 + 5.0 * scale)) + spawn_shockwave(origin, r) +} + +// Small sparks with no fireball -- bullet impacts and ricochets. +def spawn_particles(origin, color : float3; object_radius : float; count : int) { + spawn_embers(origin, color, max(object_radius, 0.18), count) +} + +def spawn_enemy_boat(pos : float3) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, EnemyBoat( + pos = pos, + vel = float3(0.0), + health = 1, + shoot_timer = random_range(0.5, BOAT_SHOOT_INTERVAL), + patrol_dir = random_sign(), + lane_prefer_x = pos.x + )) + } +} + +def spawn_enemy_plane(pos : float3) { + let spd = PLANE_SPEED * section_speed_mult() + create_entity() @(eid, cmp) { + apply_decs_template(cmp, EnemyPlane( + pos = pos, + vel = float3(random_sign() * spd, 0.0, 0.0), + health = 1, + shoot_timer = random_range(0.3, PLANE_SHOOT_INTERVAL) + )) + } +} + +def spawn_enemy_heli(pos : float3) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, EnemyHelicopter( + pos = pos, + vel = float3(0.0), + health = 2, + shoot_timer = random_range(0.5, ENEMY_HELI_SHOOT_INTERVAL), + hover_phase = random_f() * 2.0 * PI, + target_x = pos.x + )) + } +} + +def spawn_fuel_depot(pos : float3) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, FuelDepot(pos = pos)) + } +} + +def spawn_bridge(pos : float3) { + let seg = sample_river_segment(pos.y) + let bounds = ( + seg.split + ? (random_f() < 0.5 + ? float2(seg.left_bank_x, seg.split_left_x) + : float2(seg.split_right_x, seg.right_bank_x)) + : float2(seg.left_bank_x, seg.right_bank_x) + ) + let center = (bounds.x + bounds.y) * 0.5 + let width = bounds.y - bounds.x + let gap_half = BRIDGE_GAP_WIDTH * 0.5 + let min_gap_center = bounds.x + gap_half + 0.1 + let max_gap_center = bounds.y - gap_half - 0.1 + let r = random_f() + let gap_raw = ( + r < 0.33 + ? bounds.x + width * 0.2 + : (r < 0.66 ? center : bounds.y - width * 0.2) + ) + let gap_x = ( + min_gap_center <= max_gap_center + ? clamp(gap_raw, min_gap_center, max_gap_center) + : center + ) + let bridge_health = min( + BRIDGE_HEALTH_MAX, + BRIDGE_HEALTH_MIN + current_section / BRIDGE_HEALTH_STEP_SECTIONS + ) + create_entity() @(eid, cmp) { + apply_decs_template(cmp, Bridge( + pos = pos, + gap_center_x = gap_x, + left_x = bounds.x, + right_x = bounds.y, + health = bridge_health + )) + } +} + +def spawn_island(pos : float3) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, Island( + pos = pos, + size = random_range(ISLAND_SIZE_MIN, ISLAND_SIZE_MAX) + )) + } +} + +// Pick what grows here. Reeds only make sense at the waterline, boulders and +// bushes fill the near bank, and the taller silhouettes go further back -- which +// is also how a real bank stacks up from the water. +def flora_kind_for(shore_dist : float) : int { + let r = random_f() + if (shore_dist < 2.6) { + return (r < 0.62 ? FLORA_REEDS : (r < 0.85 ? FLORA_BUSH : FLORA_ROCK)) + } + if (shore_dist < 7.0) { + if (r < 0.30) { return FLORA_BUSH; } + if (r < 0.46) { return FLORA_ROCK; } + if (r < 0.72) { return FLORA_BROADLEAF; } + if (r < 0.86) { return FLORA_CONIFER; } + return FLORA_DEAD + } + if (r < 0.42) { return FLORA_CONIFER; } + if (r < 0.70) { return FLORA_BROADLEAF; } + if (r < 0.82) { return FLORA_BUSH; } + if (r < 0.92) { return FLORA_ROCK; } + return FLORA_DEAD +} + +def spawn_flora(pos : float3; kind : int; size : float) { + var ground = terrain_height(pos.x, pos.y) + for (dx in fixed_array(-size * 0.4, size * 0.4)) { + ground = max(ground, terrain_height(pos.x + dx, pos.y)) + } + let grounded = float3(pos.x, pos.y, ground) + create_entity() @(eid, cmp) { + apply_decs_template(cmp, RiverTree( + pos = grounded, + size = size, + tiers = 3, + green_tint = random_f(), + green_shift = random_range(-0.12, 0.12), + trunk_ratio = 0.35, + kind = kind, + seed = int(random_f() * 1000.0), + yaw = random_f() * 2.0 * PI + )) + } +} + +def spawn_river_tree(pos : float3; size : float; tiers : int; green_tint, green_shift, trunk_ratio : float) { + let grounded = float3(pos.x, pos.y, terrain_height(pos.x, pos.y)) + create_entity() @(eid, cmp) { + apply_decs_template(cmp, RiverTree( + pos = grounded, + size = size, + tiers = tiers, + green_tint = green_tint, + green_shift = green_shift, + trunk_ratio = trunk_ratio, + kind = FLORA_CONIFER, + seed = int(random_f() * 1000.0), + yaw = random_f() * 2.0 * PI + )) + } +} + +def spawn_river_house(pos : float3; size, body_tint, roof_tint : float) { + // Sample the footprint corners and stand on the highest one, so a house on + // a slope rests on the ground instead of being half-buried by it. + var ground = terrain_height(pos.x, pos.y) + for (dx in fixed_array(-size, size)) { + for (dy in fixed_array(-size, size)) { + ground = max(ground, terrain_height(pos.x + dx, pos.y + dy)) + } + } + let grounded = float3(pos.x, pos.y, ground) + create_entity() @(eid, cmp) { + apply_decs_template(cmp, RiverHouse( + pos = grounded, + size = size, + body_tint = body_tint, + roof_tint = roof_tint + )) + } +} + +def spawn_bonus_pickup(pos : float3; bonus_type : BonusType) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, BonusPickup( + pos = pos, + bonus_type = bonus_type, + bob_phase = random_f() * 2.0 * PI + )) + } +} + +def find_safe_respawn_pos(from_pos : float3) : float3 { + // Keep respawn roughly where player died (same Y), only nudge X to a safe lane. + let seg = sample_river_segment(from_pos.y) + let margin = PLAYER_SIZE * 1.8 + if (!seg.split) { + return float3( + clamp(from_pos.x, seg.left_bank_x + margin, seg.right_bank_x - margin), + from_pos.y, + PLAYER_Z + ) + } + + let lx0 = seg.left_bank_x + margin + let lx1 = seg.split_left_x - margin + let rx0 = seg.split_right_x + margin + let rx1 = seg.right_bank_x - margin + + // If already inside one split lane, keep that lane. + if (from_pos.x >= lx0 && from_pos.x <= lx1) { + return float3(clamp(from_pos.x, lx0, lx1), from_pos.y, PLAYER_Z) + } + if (from_pos.x >= rx0 && from_pos.x <= rx1) { + return float3(clamp(from_pos.x, rx0, rx1), from_pos.y, PLAYER_Z) + } + + // Otherwise choose nearest lane center with minimal displacement. + let lc = (lx0 + lx1) * 0.5 + let rc = (rx0 + rx1) * 0.5 + let x = (abs(from_pos.x - lc) < abs(from_pos.x - rc) ? lc : rc) + return float3(x, from_pos.y, PLAYER_Z) +} + +def spawn_section_objects() { + // Determine a Y range ahead of player to place objects + let base_y = player_pos.y + 40.0 + let section_mult = float(min(current_section + 1, 5)) + spawn_section_bridges(base_y, section_mult) + spawn_section_islands(base_y, section_mult) + spawn_section_trees(base_y) + spawn_section_houses(base_y) + spawn_section_depots(base_y) + spawn_section_enemies(base_y, section_mult) +} + +def spawn_section_bridges(base_y, section_mult : float) { + let bridge_count = int(1.0 + section_mult * 0.4) + for (i in range(bridge_count)) { + let by = base_y + SECTION_LENGTH * (float(i + 1) / float(bridge_count + 1)) + spawn_bridge(float3(0.0, by, BRIDGE_Z)) + } + commit() +} + +def spawn_section_islands(base_y, section_mult : float) { + let island_count = int(section_mult * 0.8 + random_f() * 2.0) + for (_i in range(island_count)) { + // six tries; the island is skipped when only split terrain gets sampled + for (_try in range(6)) { + let iy = base_y + random_range(10.0, SECTION_LENGTH - 10.0) + let seg = sample_river_segment(iy) + if (seg.split) { + continue + } + let ibounds = river_clamp_x_for(iy, random_sign() * 20.0) + let icx = (ibounds.x + ibounds.y) * 0.5 + random_range(-1.5, 1.5) + spawn_island(float3(icx, iy, ISLAND_Z)) + break + } + } + commit() +} + +// River-side trees — denser and varied. +// Bank dressing. The old pass scattered ~12 conifers over a 240m section, which +// is why the banks read as empty fields with the occasional lollipop. This one +// plants roughly six times as much and draws from the whole flora vocabulary, +// clustered rather than evenly sprinkled -- real banks come in thickets with +// gaps between them, not in a uniform scatter. +def spawn_section_trees(base_y : float) { + let clumps = int(26.0 + random_f() * 12.0) + for (_c in range(clumps)) { + let cy = base_y + random_range(6.0, SECTION_LENGTH - 6.0) + let left_side = random_f() < 0.5 + let members = 1 + int(random_f() * 4.0) + for (_m in range(members)) { + let ty = cy + random_range(-4.5, 4.5) + let seg = sample_river_segment(ty) + let bank_x = (left_side ? seg.left_bank_x : seg.right_bank_x) + // Distance inland, biased toward the waterline where the eye is. + let r = random_f() + let away = (r < 0.30 ? random_range(0.2, 2.6) + : (r < 0.72 ? random_range(2.6, 7.0) : random_range(7.0, 16.0))) + let tx = bank_x + (left_side ? -away : away) + let kind = flora_kind_for(away) + let base_size = (kind == FLORA_REEDS ? random_range(0.8, 1.4) + : (kind == FLORA_BUSH ? random_range(0.7, 1.2) + : (kind == FLORA_ROCK ? random_range(0.35, 0.95) + : random_range(0.95, 2.2)))) + spawn_flora(float3(tx, ty, 0.0), kind, base_size) + } + } + commit() +} + +// Occasional houses on banks. +def spawn_section_houses(base_y : float) { + let house_attempts = int(3.0 + random_f() * 3.0) + for (_i in range(house_attempts)) { + if (random_f() > 0.42) { + continue + } + // eight tries; the house is skipped when no clear spot near the bank turns up + for (_try in range(8)) { + let hy = base_y + random_range(12.0, SECTION_LENGTH - 12.0) + let seg = sample_river_segment(hy) + let left_side = random_f() < 0.5 + let bank_x = (left_side ? seg.left_bank_x : seg.right_bank_x) + let away = random_range(2.8, 9.0) + let hx = bank_x + (left_side ? -away : away) + let hsize = random_range(0.8, 1.55) + + var clear_of_trees = true + query() $(t : RiverTree) { + if (clear_of_trees) { + let min_dist = hsize * 1.5 + t.size * 0.9 + let d2 = dist2d_sq(float3(hx, hy, 0.0), t.pos) + if (d2 < min_dist * min_dist) { + clear_of_trees = false + } + } + } + + if (!clear_of_trees) { + continue + } + + let body_tint = random_range(0.0, 1.0) + let roof_tint = random_range(0.0, 1.0) + spawn_river_house(float3(hx, hy, 0.0), hsize, body_tint, roof_tint) + break + } + } + commit() +} + +// Fuel depots — spaced evenly and never on top of islands +// At drain=1.8/s and min speed 4u/s, one section takes ~60s = 108 fuel total +def spawn_section_depots(base_y : float) { + let depot_spacing = 70.0 + let depot_count = int(SECTION_LENGTH / depot_spacing) + for (i in range(depot_count)) { + for (_try in range(12)) { + let depot_y = base_y + depot_spacing * (float(i) + 0.3 + random_f() * 0.4) + let depot_bounds = river_clamp_x_for(depot_y, random_sign() * 20.0) + let shore_margin = FUEL_DEPOT_SIZE * 1.25 + 0.5 + if (depot_bounds.y - depot_bounds.x <= shore_margin * 2.0) { + continue + } + let depot_cx = random_range(depot_bounds.x + shore_margin, depot_bounds.y - shore_margin) + let candidate = float3(depot_cx, depot_y, FUEL_DEPOT_Z) + if (is_out_of_river(candidate)) { + continue + } + + var blocked = false + find_query() $(isle : Island) { + let rr = isle.size + FUEL_DEPOT_SIZE + 0.45 + if (dist2d_sq(isle.pos, candidate) < rr * rr) { + blocked = true + return true + } + return false + } + + if (!blocked) { + spawn_fuel_depot(candidate) + break + } + } + } + commit() +} + +// A boat may not spawn on an island, on a fuel depot, or under a bridge span. +def boat_spot_blocked(candidate : float3) : bool { + var blocked = false + find_query() $(isle : Island) { + let rr = isle.size + BOAT_SIZE + 0.4 + if (dist2d_sq(isle.pos, candidate) < rr * rr) { + blocked = true + return true + } + return false + } + if (!blocked) { + find_query() $(d : FuelDepot) { + let rr = FUEL_DEPOT_SIZE + BOAT_SIZE + 0.45 + if (dist2d_sq(d.pos, candidate) < rr * rr) { + blocked = true + return true + } + return false + } + } + if (!blocked) { + find_query() $(br : Bridge) { + if ( + abs(candidate.y - br.pos.y) < 1.2 && + candidate.x > br.left_x - BOAT_SIZE && + candidate.x < br.right_x + BOAT_SIZE + ) { + blocked = true + return true + } + return false + } + } + return blocked +} + +// Enemies — staggered ahead +def spawn_section_enemies(base_y, section_mult : float) { + let boat_count = int(2.0 + section_mult * 1.0) + let plane_count = (current_section >= 1 ? int(1.0 + section_mult * 0.6) : 0) + let heli_count = (current_section >= 2 ? int(section_mult * 0.5) : 0) + + for (_i in range(boat_count)) { + for (_try in range(10)) { + let ey = base_y + random_range(5.0, SECTION_LENGTH - 5.0) + let bounds = river_clamp_x_for(ey, random_sign() * 20.0) + let ex = random_range(bounds.x + 1.0, bounds.y - 1.0) + let candidate = float3(ex, ey, BOAT_Z) + if (!boat_spot_blocked(candidate)) { + spawn_enemy_boat(candidate) + break + } + } + } + for (_i in range(plane_count)) { + let ey = base_y + random_range(5.0, SECTION_LENGTH - 5.0) + let bounds = river_clamp_x_for(ey, random_sign() * 20.0) + let side_x = (random_f() < 0.5 ? bounds.x - 3.0 : bounds.y + 3.0) + spawn_enemy_plane(float3(side_x, ey, PLANE_Z)) + } + for (_i in range(heli_count)) { + let ey = base_y + random_range(15.0, SECTION_LENGTH - 15.0) + let bounds = river_clamp_x_for(ey, random_sign() * 20.0) + let hx = (bounds.x + bounds.y) * 0.5 + random_range(-2.0, 2.0) + spawn_enemy_heli(float3(hx, ey, ENEMY_HELI_Z)) + } + commit() +} + +// --- Reset --- + +def reset_game() { + var inscope to_delete : array + query() $(eid : EntityId) { + to_delete |> push(eid) + } + for (eid in to_delete) { + delete_entity(eid) + } + commit() + + score = 0 + player_lives = 3 + player_pos = float3(0.0, 0.0, PLAYER_Z) + player_vel = float3(0.0) + player_fwd_speed = PLAYER_FWD_SPEED_MIN + player_fuel = PLAYER_FUEL_MAX + player_invuln_timer = 0.0 + player_respawn_timer = 0.0 + player_shoot_cooldown = 0.0 + player_multishot_timer = 0.0 + player_fastshot_timer = 0.0 + cam_x = 0.0 + current_section = 0 + section_dist = 0.0 + section_banner_timer = 0.0 + restart_input_lock = 0.0 + screen_shake_amount = 0.0 + slow_mo_timer = 0.0 + low_fuel_beep_timer = 0.0 + refuel_sound_timer = 0.0 + set_env_immediate(0) + + global_rng_seed = uint(get_uptime() * 1000.0) + init_river() + spawn_section_objects() + game_state = GameState.playing +} + +// --- Player Update --- + +def update_player_input() { + // Keyboard + let key_left = ( + glfwGetKey(live_window, GLFW_KEY_A) == GLFW_PRESS || + glfwGetKey(live_window, GLFW_KEY_LEFT) == GLFW_PRESS + ) + let key_right = ( + glfwGetKey(live_window, GLFW_KEY_D) == GLFW_PRESS || + glfwGetKey(live_window, GLFW_KEY_RIGHT) == GLFW_PRESS + ) + let key_up = ( + glfwGetKey(live_window, GLFW_KEY_W) == GLFW_PRESS || + glfwGetKey(live_window, GLFW_KEY_UP) == GLFW_PRESS + ) + let key_down = ( + glfwGetKey(live_window, GLFW_KEY_S) == GLFW_PRESS || + glfwGetKey(live_window, GLFW_KEY_DOWN) == GLFW_PRESS + ) + + // Compose strafe and speed inputs + var strafe_input = 0.0 + if (key_left) { strafe_input--; } + if (key_right) { strafe_input++; } + + var speed_input = 0.0 + if (key_up) { speed_input++; } + if (key_down) { speed_input--; } + + player_vel.x += strafe_input * PLAYER_STRAFE_SPEED * tick_dt * 6.0 + player_fwd_speed = clamp( + player_fwd_speed + speed_input * 4.0 * tick_dt, + PLAYER_FWD_SPEED_MIN, + PLAYER_FWD_SPEED_MAX * section_speed_mult() + ) +} + +def spawn_player_bullet(pos, vel : float3) { + create_entity() @(eid, cmp) { + apply_decs_template(cmp, PlayerBullet( + pos = pos, + vel = vel, + age = 0.0 + )) + } +} + +def rotate_xy(v : float2; angle : float) : float2 { + let ca = cos(angle) + let sa = sin(angle) + return float2(v.x * ca - v.y * sa, v.x * sa + v.y * ca) +} + +def update_player_shoot() { + player_shoot_cooldown = max(player_shoot_cooldown - tick_dt, 0.0) + let fire = glfwGetKey(live_window, GLFW_KEY_SPACE) == GLFW_PRESS + if (fire && player_shoot_cooldown <= 0.0 && player_respawn_timer <= 0.0) { + let max_lateral = BULLET_SPEED * 0.259 // sin(15°) ≈ 0.259 + let lateral = clamp(player_vel.x, -max_lateral, max_lateral) + let fwd = sqrt(BULLET_SPEED * BULLET_SPEED - lateral * lateral) + let muzzle = player_pos + float3(0.0, 0.8, 0.0) + let base_dir = float2(lateral, fwd) + if (player_multishot_timer > 0.0) { + for (deg in fixed_array(-7.5, 0.0, 7.5)) { + let d = rotate_xy(base_dir, deg * PI / 180.0) + spawn_player_bullet(muzzle, float3(d.x, d.y, 0.0)) + } + } else { + spawn_player_bullet(muzzle, float3(base_dir.x, base_dir.y, 0.0)) + } + play_sfx(snd_shoot) + player_shoot_cooldown = (player_fastshot_timer > 0.0 ? FIRE_COOLDOWN * 0.5 : FIRE_COOLDOWN) + } +} + +def update_player_movement() { + player_vel.x *= PLAYER_FRICTION + player_vel.y = player_fwd_speed + + player_pos += player_vel * tick_dt + + // Clamp to river + let bounds = river_clamp_x(player_pos.y) + let margin = PLAYER_SIZE * 1.5 + let bank_hit = player_pos.x < bounds.x + margin || player_pos.x > bounds.y - margin + player_pos.x = clamp(player_pos.x, bounds.x + margin, bounds.y - margin) + player_pos.z = PLAYER_Z + + if (bank_hit && player_invuln_timer <= 0.0 && player_respawn_timer <= 0.0) { + on_player_killed() + } + + // Fuel + if (!god_mode) { + player_fuel = max(player_fuel - PLAYER_FUEL_DRAIN * tick_dt, 0.0) + } + if (player_fuel <= 0.0 && player_respawn_timer <= 0.0) { + on_player_killed() + } +} + +def on_player_killed() { + if (god_mode) { + player_invuln_timer = max(player_invuln_timer, PLAYER_INVULN_TIME) + player_fuel = PLAYER_FUEL_MAX + return + } + player_lives-- + spawn_explosion(player_pos, float3(1.0, 0.55, 0.18), PLAYER_SIZE * 2.6, 2.2) + play_sfx(snd_player_hit) + add_screen_shake(SHAKE_MED) + if (player_lives > 0) { + slow_mo_timer = SLOW_MO_DURATION + } + player_respawn_timer = PLAYER_RESPAWN_HIDE + PLAYER_RESPAWN_BLINK + player_invuln_timer = player_respawn_timer + PLAYER_INVULN_TIME + player_fuel = PLAYER_FUEL_MAX * 0.7 + player_pos = find_safe_respawn_pos(player_pos) + player_vel.x = 0.0 + if (player_lives <= 0) { + play_sfx(snd_game_over) + game_state = GameState.game_over_state + restart_input_lock = 1.5 + } +} + +def update_player(var _esc_just : bool) { + if (game_state != GameState.playing) { + return + } + + player_respawn_timer = max(player_respawn_timer - tick_dt, 0.0) + player_invuln_timer = max(player_invuln_timer - tick_dt, 0.0) + player_multishot_timer = max(player_multishot_timer - tick_dt, 0.0) + player_fastshot_timer = max(player_fastshot_timer - tick_dt, 0.0) + + // Low fuel beep + if (player_fuel < LOW_FUEL_THRESHOLD) { + low_fuel_beep_timer = max(low_fuel_beep_timer - tick_dt, 0.0) + if (low_fuel_beep_timer <= 0.0) { + play_sfx(snd_low_fuel_beep) + low_fuel_beep_timer = 0.5 + } + } + + if (player_respawn_timer > PLAYER_RESPAWN_BLINK) { + // Hidden/respawning — still scroll forward but no input + player_pos.y += player_fwd_speed * tick_dt + return + } + + update_player_input() + update_player_shoot() + update_player_movement() +} + +// --- Section Management --- + +def update_section() { + if (game_state != GameState.playing) { + return + } + + section_dist += player_fwd_speed * tick_dt + section_banner_timer = max(section_banner_timer - tick_dt, 0.0) + if (section_color_blend_t < 1.0) { + // The whole lighting environment crossfades, not just the water tint, + // so a section change reads as flying into different weather. + section_color_blend_t = min(section_color_blend_t + tick_dt * 0.35, 1.0) + refresh_env() + } + + if (section_dist >= SECTION_LENGTH) { + section_dist -= SECTION_LENGTH + current_section++ + section_banner_timer = WAVE_BANNER_DURATION + play_sfx(snd_section_clear) + score += 500 * (current_section) + begin_env_transition(section_idx()) + + if (current_section >= MAX_SECTIONS) { + game_state = GameState.win_state + restart_input_lock = 1.5 + } else { + spawn_section_objects() + } + } +} + +// --- Enemy Updates --- + +def enemy_shoot_at_player(from : float3; btype : int = 0) { + let dir = normalize(player_pos - from) + let spd = (btype == 1 ? ENEMY_HELI_BULLET_SPEED : ENEMY_BULLET_SPEED) + create_entity() @(eid, cmp) { + apply_decs_template(cmp, EnemyBullet( + pos = from + dir * 0.8, + vel = dir * spd, + age = 0.0, + bullet_type = btype + )) + } +} + +def update_enemies() { + let sm = section_speed_mult() + update_enemy_boats(sm) + update_enemy_planes(sm) + update_enemy_helis(sm) + update_bullets() + age_out_particles() +} + +// Boats: patrol horizontally, shoot at player +def update_enemy_boats(sm : float) { + query() $(eid : EntityId; var b : EnemyBoat) { + // Scroll with world — boats stay at their world Y, player moves to them + b.shoot_timer -= tick_dt + // Patrol + let bounds = river_clamp_x_for(b.pos.y, b.lane_prefer_x) + let lane_width = max(bounds.y - bounds.x, 0.01) + let margin = clamp(lane_width * 0.5 - 0.08, 0.05, BOAT_SIZE * 2.0) + let lane_min = bounds.x + margin + let lane_max = bounds.y - margin + b.vel.x = b.patrol_dir * BOAT_PATROL_SPEED * sm + b.pos.x += b.vel.x * tick_dt + if (lane_min >= lane_max) { + b.patrol_dir = -b.patrol_dir + b.pos.x = (bounds.x + bounds.y) * 0.5 + } elif (b.pos.x <= lane_min || b.pos.x >= lane_max) { + b.patrol_dir = -b.patrol_dir + b.pos.x = clamp(b.pos.x, lane_min, lane_max) + } + // Avoid islands — reverse patrol direction and back up if overlapping + find_query() $(isle : Island) { + let r = isle.size + BOAT_SIZE + 0.3 + let dx = b.pos.x - isle.pos.x + let dy = b.pos.y - isle.pos.y + if (dx * dx + dy * dy < r * r) { + b.patrol_dir = -b.patrol_dir + b.pos.x -= b.vel.x * tick_dt * 2.0 + return true + } + return false + } + // Avoid fuel depots (platforms) so ships don't clip through them + find_query() $(d : FuelDepot) { + let r = FUEL_DEPOT_SIZE + BOAT_SIZE + 0.35 + let dx = b.pos.x - d.pos.x + let dy = b.pos.y - d.pos.y + if (dx * dx + dy * dy < r * r) { + b.patrol_dir = -b.patrol_dir + b.pos.x += (dx >= 0.0 ? 1.0 : -1.0) * tick_dt * BOAT_PATROL_SPEED * sm * 2.5 + return true + } + return false + } + // Shoot when in range + if (b.shoot_timer <= 0.0 && abs(b.pos.y - player_pos.y) < 20.0) { + enemy_shoot_at_player(b.pos, 0) + b.shoot_timer = BOAT_SHOOT_INTERVAL / sm + } + } + commit() +} + +// Planes: fly sideways, shoot when passing player Y +def update_enemy_planes(sm : float) { + query() $(eid : EntityId; var p : EnemyPlane) { + p.pos += p.vel * tick_dt + p.shoot_timer -= tick_dt + // Bounce off banks + terrain + let bounds = river_clamp_x_for(p.pos.y, p.pos.x) + if (p.pos.x < bounds.x - 4.0 || p.pos.x > bounds.y + 4.0) { + p.vel.x = -p.vel.x + p.pos.x = clamp(p.pos.x, bounds.x - 4.0, bounds.y + 4.0) + } + if (p.shoot_timer <= 0.0 && abs(p.pos.y - player_pos.y) < 15.0) { + enemy_shoot_at_player(p.pos, 2) + p.shoot_timer = PLANE_SHOOT_INTERVAL / sm + } + } + commit() +} + +// Helicopters: hover and orbit player +def update_enemy_helis(sm : float) { + query() $(eid : EntityId; var h : EnemyHelicopter) { + h.hover_phase += tick_dt * 1.8 + let hover_z = ENEMY_HELI_Z + sin(h.hover_phase) * 0.4 + + // Slowly track player X + let tx = player_pos.x + sin(h.hover_phase * 0.5) * 2.5 + h.pos.x += (tx - h.pos.x) * tick_dt * ENEMY_HELI_SPEED + h.pos.z = hover_z + + h.shoot_timer -= tick_dt + if (h.shoot_timer <= 0.0 && abs(h.pos.y - player_pos.y) < ENEMY_HELI_RANGE) { + enemy_shoot_at_player(h.pos, 1) + h.shoot_timer = ENEMY_HELI_SHOOT_INTERVAL_FAST / sm + } + } + commit() +} + +// Move player bullets; delete on expiry or island collision +def update_bullets() { + var inscope pb_island_hits : array> + query() $(eid : EntityId; var b : PlayerBullet) { + b.age += tick_dt + b.pos += b.vel * tick_dt + spawn_trail(b.pos, float3(0.15, 0.85, 0.8)) + if (b.age > BULLET_LIFETIME) { + delete_entity(eid) + } else { + find_query() $(isle : Island) { + let r = isle.size + BULLET_SIZE + let dx = b.pos.x - isle.pos.x + let dy = b.pos.y - isle.pos.y + if (dx * dx + dy * dy < r * r) { + pb_island_hits |> push(eid => b.pos) + return true + } + return false + } + } + } + commit() + for (h in pb_island_hits) { + delete_entity(h._0) + spawn_particles(h._1, float3(1.0, 1.0, 0.6), 0.2, 4) + } + commit() + + query() $(eid : EntityId; var b : EnemyBullet) { + b.age += tick_dt + b.pos += b.vel * tick_dt + let tc = (b.bullet_type == 1 ? float3(0.9, 0.1, 0.1) : (b.bullet_type == 2 ? float3(0.9, 0.55, 0.1) : float3(0.8, 0.6, 0.2))) + spawn_trail(b.pos, tc) + if (b.age > ENEMY_BULLET_LIFETIME) { + delete_entity(eid) + } + } + commit() +} + +// Age out particles +def step_particles(dt : float) { + query() $(eid : EntityId; var p : Particle) { + p.lifetime -= dt + // Drag and gravity ride on the particle, so one integrator covers + // rising smoke, arcing embers and tumbling debris. A negative gravity + // is buoyancy: that is how the hot layers climb. + if (p.drag > 0.0) { + p.vel *= max(1.0 - p.drag * dt, 0.0) + } + if (p.gravity != 0.0) { + p.vel.z -= p.gravity * dt + } + p.pos += p.vel * dt + // Debris that reaches the water stops there and skids out. + if (p.kind == ParticleKind.debris && p.pos.z < 0.05) { + p.pos.z = 0.05 + p.vel = float3(p.vel.x * 0.4, p.vel.y * 0.4, 0.0) + } + if (p.lifetime <= 0.0) { + delete_entity(eid) + } + } + commit() +} + +def age_out_particles() { + if (fx_freeze) { + return + } + step_particles(tick_dt) +} + +// --- Culling: remove entities far behind player --- + +def cull_far_entities() { + let cull_y = player_pos.y - 20.0 + var inscope to_del : array + query() $(eid : EntityId; b : EnemyBoat) { + if (b.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; p : EnemyPlane) { + if (p.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; h : EnemyHelicopter) { + if (h.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; b : Bridge) { + if (b.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; isle : Island) { + if (isle.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; d : FuelDepot) { + if (d.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; t : RiverTree) { + if (t.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; h : RiverHouse) { + if (h.pos.y < cull_y) { to_del |> push(eid); } + } + query() $(eid : EntityId; b : BonusPickup) { + if (b.pos.y < cull_y) { to_del |> push(eid); } + } + for (eid in to_del) { + delete_entity(eid) + } + commit() +} + +// --- Collision Detection --- + +def dist2d_sq(a, b : float3) : float { + let dx = a.x - b.x + let dy = a.y - b.y + return dx * dx + dy * dy +} + +def process_collisions() { + if (game_state != GameState.playing || player_respawn_timer > PLAYER_RESPAWN_BLINK) { + return + } + var inscope enemy_hits <- collect_bullet_hits() + resolve_enemy_hits(enemy_hits) + enemy_bullets_vs_player() + player_vs_fuel_depots() + player_vs_bonuses() + player_vs_islands() + player_vs_bridges() +} + +// Player bullets vs enemies — hits are collected first because a decs query +// may not mutate the entity set it is walking. +def collect_bullet_hits() : array { // nolint:STYLE037,STYLE038 - one find_query arm per enemy kind, in hit-priority order + var enemy_hits : array + query() $(eid aka b_eid : EntityId; pb : PlayerBullet) { + var bullet_hit = false + // vs boats + find_query() $(eid : EntityId; b : EnemyBoat) { + let r = BOAT_SIZE + BULLET_SIZE + if (dist2d_sq(pb.pos, b.pos) < r * r) { + enemy_hits |> push(EnemyHitInfo( + bullet_eid = b_eid, + enemy_eid = eid, + pos = b.pos, + enemy_kind = EnemyKind.boat, + score_value = 100, + blast_radius = BOAT_SIZE + )) + bullet_hit = true + return true + } + return false + } + // vs planes + if (!bullet_hit) { + find_query() $(eid : EntityId; p : EnemyPlane) { + let r = PLANE_SIZE + BULLET_SIZE + if (dist2d_sq(pb.pos, p.pos) < r * r) { + enemy_hits |> push(EnemyHitInfo( + bullet_eid = b_eid, + enemy_eid = eid, + pos = p.pos, + enemy_kind = EnemyKind.plane, + score_value = 150, + blast_radius = PLANE_SIZE + )) + bullet_hit = true + return true + } + return false + } + } + // vs enemy helis + if (!bullet_hit) { + find_query() $(eid : EntityId; h : EnemyHelicopter) { + let r = ENEMY_HELI_SIZE + BULLET_SIZE + if (dist2d_sq(pb.pos, h.pos) < r * r) { + enemy_hits |> push(EnemyHitInfo( + bullet_eid = b_eid, + enemy_eid = eid, + pos = h.pos, + enemy_kind = EnemyKind.helicopter, + score_value = 200, + blast_radius = ENEMY_HELI_SIZE + )) + bullet_hit = true + return true + } + return false + } + } + // vs bridges + if (!bullet_hit) { + find_query() $(eid : EntityId; br : Bridge) { + if (abs(pb.pos.y - br.pos.y) < 0.8) { + let gap_l = br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 + let gap_r = br.gap_center_x + BRIDGE_GAP_WIDTH * 0.5 + let in_left_span = pb.pos.x >= br.left_x - BULLET_SIZE && pb.pos.x <= gap_l + BULLET_SIZE + let in_right_span = pb.pos.x >= gap_r - BULLET_SIZE && pb.pos.x <= br.right_x + BULLET_SIZE + if (in_left_span || in_right_span) { + enemy_hits |> push(EnemyHitInfo( + bullet_eid = b_eid, + enemy_eid = eid, + pos = br.pos, + enemy_kind = EnemyKind.bridge, + score_value = 50, + blast_radius = (br.right_x - br.left_x) * 0.5 + )) + bullet_hit = true + return true + } + } + return false + } + } + // vs fuel depots — one-shot explodable + if (!bullet_hit) { + find_query() $(eid : EntityId; d : FuelDepot) { + let r = FUEL_DEPOT_SIZE + BULLET_SIZE + if (dist2d_sq(pb.pos, d.pos) < r * r) { + enemy_hits |> push(EnemyHitInfo( + bullet_eid = b_eid, + enemy_eid = eid, + pos = d.pos, + enemy_kind = EnemyKind.depot, + score_value = 75, + blast_radius = FUEL_DEPOT_SIZE + )) + return true + } + return false + } + } + } + return <- enemy_hits +} + +def resolve_enemy_hits(enemy_hits : array) { + var inscope killed_this_tick : array + for (h in enemy_hits) { + delete_entity(h.bullet_eid) + var already_killed = false + for (eid in killed_this_tick) { + if (eid == h.enemy_eid) { + already_killed = true + break + } + } + if (already_killed) { + continue + } + spawn_particles(h.pos, float3(1.0, 0.95, 0.5), max(0.15, h.blast_radius * 0.2), 4) + var is_depot = false + if (damage_enemy(h, is_depot)) { + delete_entity(h.enemy_eid) + killed_this_tick |> push(h.enemy_eid) + on_enemy_destroyed(h, is_depot) + } + } + commit() +} + +// Applies one hit to whichever enemy kind owns h.enemy_eid; true once its health runs out. +def damage_enemy(h : EnemyHitInfo; var is_depot : bool&) : bool { // nolint:STYLE037 - one find_query arm per enemy kind + var killed = false + find_query() $(eid : EntityId; var b : EnemyBoat) { + if (eid == h.enemy_eid) { + if (b.health > 0) { + b.health-- + if (b.health <= 0) { killed = true; } + } + return true + } + return false + } + find_query() $(eid : EntityId; var p : EnemyPlane) { + if (eid == h.enemy_eid) { + if (p.health > 0) { + p.health-- + if (p.health <= 0) { killed = true; } + } + return true + } + return false + } + find_query() $(eid : EntityId; var hh : EnemyHelicopter) { + if (eid == h.enemy_eid) { + if (hh.health > 0) { + hh.health-- + if (hh.health <= 0) { killed = true; } + } + return true + } + return false + } + // Bridges now take multiple hits. + find_query() $(eid : EntityId; var br : Bridge) { + if (eid == h.enemy_eid) { + if (br.health > 0) { + br.health-- + if (br.health <= 0) { + killed = true + } + } + return true + } + return false + } + // Fuel depots — always one-shot + find_query() $(eid : EntityId; d : FuelDepot) { + if (eid == h.enemy_eid) { + killed = true + is_depot = true + return true + } + return false + } + return killed +} + +// Death effects and loot for an enemy whose entity has just been deleted. +def on_enemy_destroyed(h : EnemyHitInfo; is_depot : bool) { + var is_bridge = false + find_query() $(eid : EntityId; br : Bridge) { + if (eid == h.enemy_eid) { is_bridge = true; return true; } + return false + } + if (is_depot) { + // A depot goes up twice: the tank at deck level, then a second bloom + // above it a beat later. + spawn_explosion(h.pos, float3(1.0, 0.52, 0.06), h.blast_radius * 1.2, 1.8) + spawn_explosion(h.pos + float3(0.0, 0.0, 1.1), float3(0.95, 0.28, 0.02), h.blast_radius * 0.8, 1.2) + } else { + spawn_explosion(h.pos, float3(1.0, 0.66, 0.12), h.blast_radius, (is_bridge ? 1.6 : 1.0)) + } + play_sfx(is_bridge ? snd_bridge_destroy : snd_enemy_explode) + if (is_bridge) { + add_screen_shake(SHAKE_BIG) + } elif (is_depot) { + add_screen_shake(SHAKE_MED) + } else { + add_screen_shake(SHAKE_SMALL) + } + score += h.score_value + + let drop_chance = ( + h.enemy_kind == EnemyKind.helicopter ? 0.75 + : (h.enemy_kind == EnemyKind.plane ? 0.55 + : (h.enemy_kind == EnemyKind.boat ? BONUS_DROP_CHANCE : 0.0)) + ) + if (drop_chance > 0.0 && random_f() < drop_chance) { + let r = random_f() + let bt = ( + r < 0.3 + ? BonusType.fuel + : (r < 0.6 ? BonusType.multishot : (r < 0.9 ? BonusType.fastshot : BonusType.life)) + ) + let drop_bounds = river_clamp_x(h.pos.y) + let drop_x = clamp(h.pos.x, drop_bounds.x + BONUS_PICKUP_RADIUS, drop_bounds.y - BONUS_PICKUP_RADIUS) + spawn_bonus_pickup(float3(drop_x, h.pos.y, 0.42), bt) + } +} + +// Enemy bullets vs player +def enemy_bullets_vs_player() { + if (player_invuln_timer <= 0.0) { + var player_hit = false + query() $(eid : EntityId; eb : EnemyBullet) { + if (!player_hit) { + let r = PLAYER_SIZE + ENEMY_BULLET_SIZE + if (dist2d_sq(eb.pos, player_pos) < r * r) { + player_hit = true + delete_entity(eid) + } + } + } + if (player_hit) { + on_player_killed() + } + commit() + } +} + +// Player vs fuel depots +def player_vs_fuel_depots() { + query() $(eid : EntityId; d : FuelDepot) { + let r = FUEL_DEPOT_SIZE + PLAYER_SIZE + REFUEL_RADIUS + if (dist2d_sq(d.pos, player_pos) < r * r && player_fuel < PLAYER_FUEL_MAX) { + player_fuel = min(player_fuel + REFUEL_RATE * tick_dt, PLAYER_FUEL_MAX) + refuel_sound_timer -= tick_dt + if (refuel_sound_timer <= 0.0) { + play_sfx(snd_refuel) + refuel_sound_timer = 0.7 + } + } + } +} + +// Player vs bonuses — collect burst origins/colors and spawn AFTER the +// query (create_entities can't run inside a decs query). +def player_vs_bonuses() { + var burst_origins : array + var burst_colors : array + query() $(eid : EntityId; b : BonusPickup) { + let r = PLAYER_SIZE + BONUS_PICKUP_RADIUS + if (dist2d_sq(b.pos, player_pos) < r * r) { + if (b.bonus_type == BonusType.fuel) { + player_fuel = min(player_fuel + BONUS_FUEL_AMOUNT, PLAYER_FUEL_MAX) + play_sfx(snd_bonus_fuel) + } elif (b.bonus_type == BonusType.multishot) { + player_multishot_timer = max(player_multishot_timer, BONUS_MULTISHOT_TIME) + play_sfx(snd_bonus_multishot) + } elif (b.bonus_type == BonusType.fastshot) { + player_fastshot_timer = max(player_fastshot_timer, BONUS_FASTSHOT_TIME) + play_sfx(snd_bonus_fastshot) + } else { + player_lives++ + play_sfx(snd_bonus_life) + } + burst_origins |> push(b.pos + float3(0.0, 0.0, 0.55)) + burst_colors |> push(bonus_color(b.bonus_type)) + delete_entity(eid) + } + } + for (origin, color in burst_origins, burst_colors) { + spawn_pickup_burst(origin, color) + } + commit() +} + +// Player vs islands +def player_vs_islands() { + if (player_invuln_timer <= 0.0) { + var island_hit = false + query() $(isle : Island) { + if (!island_hit) { + let r = isle.size + PLAYER_SIZE + let d2 = ( + (player_pos.x - isle.pos.x) * (player_pos.x - isle.pos.x) + + (player_pos.y - isle.pos.y) * (player_pos.y - isle.pos.y) + ) + if (d2 < r * r) { + island_hit = true + } + } + } + if (island_hit) { + on_player_killed() + } + } +} + +// Player through bridge — must pass through gap +def player_vs_bridges() { + if (player_invuln_timer <= 0.0) { + var bridge_hit = false + query() $(br : Bridge) { + if (!bridge_hit && abs(player_pos.y - br.pos.y) < 1.0) { + let in_span = player_pos.x > br.left_x && player_pos.x < br.right_x + let in_gap = ( + player_pos.x > br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 && + player_pos.x < br.gap_center_x + BRIDGE_GAP_WIDTH * 0.5 + ) + if (in_span && !in_gap) { + bridge_hit = true + } + } + } + if (bridge_hit) { + on_player_killed() + } + } +} + +// --- Rendering --- + +def quat_y_rot(angle : float) : float4 { + return float4(0.0, sin(angle * 0.5), 0.0, cos(angle * 0.5)) +} + +def quat_x_rot(angle : float) : float4 { + return float4(sin(angle * 0.5), 0.0, 0.0, cos(angle * 0.5)) +} + +def quat_z_rot(angle : float) : float4 { + return float4(0.0, 0.0, sin(angle * 0.5), cos(angle * 0.5)) +} + +// Rotate unit Z to direction v (for aligning cylinders along a velocity vector) +def quat_align_z(v : float3) : float4 { + let from = float3(0.0, 0.0, 1.0) + let d = dot(from, v) + if (d > 0.9999) { return float4(0.0, 0.0, 0.0, 1.0); } + if (d < -0.9999) { return float4(1.0, 0.0, 0.0, 0.0); } + let axis = normalize(cross(from, v)) + let half = acos(d) * 0.5 + return float4(axis * sin(half), cos(half)) +} + +// --- Model palettes --- +// +// Enemy factions share the player's meshes under different paint, which keeps +// the vocabulary readable: silhouette says what it is, colour says whose it is. + +def private player_palette(inv : bool; pulse : float) { + let base = (inv ? float3(1.0, 0.86, 0.35) : float3(0.72, 0.80, 0.88)) + set_model_palette( + base * pulse, + float3(0.16, 0.30, 0.38), + float3(0.52, 0.56, 0.60), + float3(0.10, 0.12, 0.14) + ) +} + +def private enemy_heli_palette() { + set_model_palette( + float3(0.52, 0.14, 0.10), + float3(0.14, 0.10, 0.10), + float3(0.40, 0.38, 0.36), + float3(0.08, 0.07, 0.07) + ) +} + +def private boat_palette() { + set_model_palette( + float3(0.28, 0.30, 0.27), + float3(0.46, 0.42, 0.30), + float3(0.48, 0.47, 0.45), + float3(0.09, 0.10, 0.11) + ) +} + +def private jet_palette() { + set_model_palette( + float3(0.40, 0.42, 0.47), + float3(0.14, 0.18, 0.22), + float3(0.55, 0.55, 0.58), + float3(0.08, 0.08, 0.09) + ) +} + +// Craft bank into a turn. Purely cosmetic, but it is most of what makes the +// helicopter feel like it is being flown rather than slid. +def private player_orientation() : float4 { + let roll = clamp(-player_vel.x * 0.075, -0.55, 0.55) + let pitch = clamp((player_fwd_speed - PLAYER_FWD_SPEED_MIN) * 0.018, 0.0, 0.22) + return quat_mul(quat_y_rot(roll), quat_x_rot(-pitch)) +} + +// --- Shadow map pass --- +// +// Everything solid re-draws into the light's depth buffer. The old fake blob +// shadows are gone: props now shade each other and the banks. + +// Aircraft and boats: the movers, whose shadows track the action. +def private shadow_cast_craft() { + if (player_respawn_timer <= PLAYER_RESPAWN_BLINK) { + draw_shadow_caster(player_pos, float3(PLAYER_SIZE * 1.85), player_orientation()) + geo_heli |> draw_geometry_fragment() + } + + query() $(b : EnemyBoat) { + draw_shadow_caster(b.pos, float3(BOAT_SIZE * 1.25), quat_z_rot(b.patrol_dir * 0.12)) + geo_gunboat |> draw_geometry_fragment() + } + + query() $(p : EnemyPlane) { + draw_shadow_caster(p.pos, float3(PLANE_SIZE * 1.35)) + geo_jet |> draw_geometry_fragment() + } + + query() $(h : EnemyHelicopter) { + draw_shadow_caster(h.pos, float3(ENEMY_HELI_SIZE * 1.7), quat_y_rot(sin(h.hover_phase) * 0.2)) + geo_heli |> draw_geometry_fragment() + } + +} + +// Bank scenery: trees and houses, which throw the long streaks across the +// ground that give the low sun its scale. +def private shadow_cast_scenery() { + let per_kind = max(length(geo_flora) / FLORA_VARIANTS, 1) + query() $(t : RiverTree) { + if (empty(geo_flora)) { return ; } + draw_shadow_caster(t.pos, float3(t.size), quat_z_rot(t.yaw)) + let idx = clamp(t.kind, 0, FLORA_VARIANTS - 1) * per_kind + (t.seed % per_kind) + geo_flora[clamp(idx, 0, length(geo_flora) - 1)] |> draw_geometry_fragment() + } + + query() $(h : RiverHouse) { + let body_h = h.size + draw_shadow_caster(h.pos + float3(0.0, 0.0, body_h * 0.5), + float3(h.size * 0.95, h.size * 1.1, body_h * 0.5)) + geo_cube |> draw_geometry_fragment() + draw_shadow_caster(h.pos + float3(0.0, 0.0, body_h + h.size * 0.25), + float3(h.size * 1.05, h.size * 1.18, h.size * 0.55)) + geo_prism |> draw_geometry_fragment() + } + +} + +// Obstacles standing in the water. +def private shadow_cast_obstacles() { + query() $(isle : Island) { + draw_shadow_caster(isle.pos, float3(isle.size, isle.size, isle.size * 0.35)) + geo_sphere |> draw_geometry_fragment() + } + + query() $(d : FuelDepot) { + draw_shadow_caster(d.pos, float3(FUEL_DEPOT_SIZE, FUEL_DEPOT_SIZE * 1.8, FUEL_DEPOT_SIZE * 0.25)) + geo_cube |> draw_geometry_fragment() + draw_shadow_caster(d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.9), + float3(0.14, 0.14, FUEL_DEPOT_SIZE * 0.9)) + geo_cube |> draw_geometry_fragment() + } + + query() $(br : Bridge) { + let pylon_h = BRIDGE_SPAN_Z + 0.5 + for (px in fixed_array(br.left_x, br.right_x)) { + draw_shadow_caster(float3(px, br.pos.y, pylon_h * 0.5), float3(0.4, 0.3, pylon_h)) + geo_cube |> draw_geometry_fragment() + } + let gap_left = br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 + let gap_right = br.gap_center_x + BRIDGE_GAP_WIDTH * 0.5 + let lw = gap_left - br.left_x + if (lw > 0.1) { + draw_shadow_caster(float3((br.left_x + gap_left) * 0.5, br.pos.y, BRIDGE_SPAN_Z), + float3(lw * 0.5, 0.35, 0.25)) + geo_cube |> draw_geometry_fragment() + } + let rw = br.right_x - gap_right + if (rw > 0.1) { + draw_shadow_caster(float3((gap_right + br.right_x) * 0.5, br.pos.y, BRIDGE_SPAN_Z), + float3(rw * 0.5, 0.35, 0.25)) + geo_cube |> draw_geometry_fragment() + } + } +} + +def render_shadow_casters() { + glUseProgram(shadow_program) + active_program = shadow_program + render_river_shadow() + shadow_cast_craft() + shadow_cast_scenery() + shadow_cast_obstacles() +} + +// --- Opaque scene --- + +def render_player(game_time : float) { + if (player_respawn_timer > PLAYER_RESPAWN_BLINK) { + return + } + let blinking = player_respawn_timer > 0.0 + if (blinking && int(game_time * 16.0) % 2 == 0) { + return + } + let inv = player_invuln_timer > 0.0 + let pulse = (inv ? 0.75 + 0.35 * abs(sin(game_time * 8.0)) : 1.0) + + use_prop_program() + player_palette(inv, pulse) + draw_prop(player_pos, float3(PLAYER_SIZE * 1.85), float3(1.0), player_orientation(), MAT_HULL) + geo_heli |> draw_geometry_fragment() + clear_model_palette() +} + +def render_enemies(game_time : float) { + use_prop_program() + + query() $(b : EnemyBoat) { + boat_palette() + // Heel the hull into its patrol direction and let it ride the swell. + let heel = quat_y_rot(b.patrol_dir * 0.14) + let bob = quat_x_rot(sin(game_time * 1.7 + b.pos.x) * 0.05) + draw_prop(b.pos, float3(BOAT_SIZE * 1.25), float3(1.0), quat_mul(heel, bob), MAT_HULL) + geo_gunboat |> draw_geometry_fragment() + } + + query() $(p : EnemyPlane) { + jet_palette() + let bank = quat_y_rot(clamp(p.vel.x * 0.10, -0.5, 0.5)) + draw_prop(p.pos, float3(PLANE_SIZE * 1.35), float3(1.0), bank, MAT_METAL) + geo_jet |> draw_geometry_fragment() + } + + query() $(h : EnemyHelicopter) { + enemy_heli_palette() + let drift = quat_y_rot(clamp((h.target_x - h.pos.x) * 0.06, -0.45, 0.45)) + draw_prop(h.pos, float3(ENEMY_HELI_SIZE * 1.7), float3(1.0), drift, MAT_HULL) + geo_heli |> draw_geometry_fragment() + } + + clear_model_palette() +} + +def render_obstacles(game_time : float) { + use_prop_program() + render_bridges() + render_islands() + render_fuel_depots(game_time) + render_trees() + render_houses() + render_bonuses(game_time) +} + +// Bridges: two pylons + two half-spans, with a deck lip so the gap reads. +def render_bridges() { + let pylon_color = float3(0.42, 0.40, 0.37) + let span_color = float3(0.50, 0.47, 0.43) + query() $(br : Bridge) { + let pylon_h = BRIDGE_SPAN_Z + 0.5 + for (px in fixed_array(br.left_x, br.right_x)) { + draw_prop(float3(px, br.pos.y, pylon_h * 0.5), float3(0.4, 0.3, pylon_h), + pylon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE, REFLECT_PAINT) + geo_cube |> draw_geometry_fragment() + } + + let gap_left = br.gap_center_x - BRIDGE_GAP_WIDTH * 0.5 + let gap_right = br.gap_center_x + BRIDGE_GAP_WIDTH * 0.5 + // Damage reads as the deck darkening and losing its highlight. + let wear = saturate(float(br.health) / float(BRIDGE_HEALTH_MAX)) + let deck = span_color * (0.55 + wear * 0.45) + + let lw = gap_left - br.left_x + if (lw > 0.1) { + let cx = (br.left_x + gap_left) * 0.5 + draw_prop(float3(cx, br.pos.y, BRIDGE_SPAN_Z), float3(lw * 0.5, 0.35, 0.25), + deck, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + draw_prop(float3(cx, br.pos.y - 0.36, BRIDGE_SPAN_Z + 0.22), + float3(lw * 0.5, 0.05, 0.12), deck * 1.2, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + } + let rw = br.right_x - gap_right + if (rw > 0.1) { + let cx = (gap_right + br.right_x) * 0.5 + draw_prop(float3(cx, br.pos.y, BRIDGE_SPAN_Z), float3(rw * 0.5, 0.35, 0.25), + deck, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + draw_prop(float3(cx, br.pos.y - 0.36, BRIDGE_SPAN_Z + 0.22), + float3(rw * 0.5, 0.05, 0.12), deck * 1.2, float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_cube |> draw_geometry_fragment() + } + } +} + +// Islands: a flattened dome, tinted with the section's bank colour so a rock in +// the ash channel is not the same green as one in the forest. +def render_islands() { + query() $(isle : Island) { + let island_color = get_bank_color() * 1.15 + float3(0.04) + draw_prop(isle.pos, float3(isle.size, isle.size, isle.size * 0.35), island_color, + float4(0.0, 0.0, 0.0, 1.0), MAT_STONE) + geo_sphere |> draw_geometry_fragment() + } +} + +// Fuel depots: barge, tank, and a beacon that goes green while refuelling. +def render_fuel_depots(game_time : float) { + query() $(d : FuelDepot) { + let rr = FUEL_DEPOT_SIZE + PLAYER_SIZE + REFUEL_RADIUS + let refueling = dist2d_sq(d.pos, player_pos) < rr * rr + let beacon_pulse = 0.55 + 0.45 * abs(sin(game_time * 3.5)) + + draw_prop(d.pos, float3(FUEL_DEPOT_SIZE, FUEL_DEPOT_SIZE * 1.8, FUEL_DEPOT_SIZE * 0.25), + float3(0.30, 0.31, 0.33), float4(0.0, 0.0, 0.0, 1.0), MAT_METAL, REFLECT_PAINT) + geo_cube |> draw_geometry_fragment() + + // Storage tank lying on the deck; the cylinder gives the depot a + // silhouette you can pick out of the bank clutter at range, and the + // chrome reflection is what makes it read as a pressure vessel rather + // than a painted drum. + draw_prop(d.pos + float3(0.0, 0.0, FUEL_DEPOT_SIZE * 0.45), + float3(FUEL_DEPOT_SIZE * 0.42, FUEL_DEPOT_SIZE * 0.42, FUEL_DEPOT_SIZE * 0.95), + float3(0.70, 0.66, 0.30), quat_x_rot(PI * 0.5), MAT_METAL, REFLECT_CHROME) + geo_cylinder |> draw_geometry_fragment() + + let beacon_color = ( + refueling ? float3(0.25, 1.0, 0.35) : float3(1.0, 0.72, 0.18) + ) * beacon_pulse + draw_prop(d.pos + float3(0.0, FUEL_DEPOT_SIZE * 0.9, FUEL_DEPOT_SIZE * 0.9), + float3(0.09, 0.09, FUEL_DEPOT_SIZE * 0.55), float3(0.22, 0.22, 0.24), + float4(0.0, 0.0, 0.0, 1.0), MAT_METAL, REFLECT_METAL) + geo_cube |> draw_geometry_fragment() + draw_prop(d.pos + float3(0.0, FUEL_DEPOT_SIZE * 0.9, FUEL_DEPOT_SIZE * 1.5), + float3(0.16), beacon_color, float4(0.0, 0.0, 0.0, 1.0), MAT_GLOW) + geo_sphere |> draw_geometry_fragment() + } +} + +// Bank flora. One welded mesh per prop, tinted through the model palette, so a +// dressed bank costs one draw call per plant instead of one per cone. +// Takes fields, not the template: a DECS query variable can only be accessed +// by field, never passed whole. +def private flora_palette(kind : int; green_tint, green_shift : float; tint : float3) { + if (kind == FLORA_ROCK) { + let stone = lerp(float3(0.34, 0.33, 0.31), get_bank_color() * 1.4, 0.35) + set_model_palette(stone, stone * 1.12, stone, stone * (0.86 + green_shift)) + return + } + if (kind == FLORA_DEAD) { + let wood = float3(0.26 + green_shift * 0.4, 0.20, 0.14) * tint + set_model_palette(wood, wood * 1.15, wood, wood * 0.8) + return + } + let dark = float3(0.05, 0.20, 0.07) + let light = float3(0.15, 0.42, 0.15) + var green = lerp(dark, light, green_tint) + if (kind == FLORA_REEDS) { + green = lerp(green, float3(0.42, 0.44, 0.20), 0.55) + } elif (kind == FLORA_BUSH) { + green = lerp(green, float3(0.12, 0.30, 0.10), 0.35) + } + green = (green + float3(green_shift * 0.22, green_shift * 0.06, -green_shift * 0.12)) * tint + let bark = float3(0.22 + green_shift * 0.18, 0.15, 0.09) * tint + // Two foliage tones plus bark: the accent tone is what gives a crown depth + // instead of reading as one flat silhouette. + set_model_palette(green, green * 1.28 + float3(0.02, 0.05, 0.01), green, bark) +} + +def render_trees() { + if (empty(geo_flora)) { + return + } + let tint = env_now.foliage_tint + let per_kind = length(geo_flora) / FLORA_VARIANTS + query() $(t : RiverTree) { + flora_palette(t.kind, t.green_tint, t.green_shift, tint) + let mat = (t.kind == FLORA_ROCK ? MAT_STONE : MAT_FOLIAGE) + draw_prop(t.pos, float3(t.size), float3(1.0), quat_z_rot(t.yaw), mat) + let idx = clamp(t.kind, 0, FLORA_VARIANTS - 1) * per_kind + (t.seed % max(per_kind, 1)) + geo_flora[clamp(idx, 0, length(geo_flora) - 1)] |> draw_geometry_fragment() + } + clear_model_palette() +} + +// Houses: body + prism roof + a chimney, so the roofline is not a bare wedge. +def render_houses() { + query() $(h : RiverHouse) { + let body_color = lerp(float3(0.68, 0.62, 0.53), float3(0.52, 0.58, 0.64), h.body_tint) + let roof_color = lerp(float3(0.52, 0.19, 0.16), float3(0.20, 0.23, 0.29), h.roof_tint) + + let body_h = h.size + draw_prop(h.pos + float3(0.0, 0.0, body_h * 0.5), + float3(h.size * 0.95, h.size * 1.1, body_h * 0.5), body_color, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) + geo_cube |> draw_geometry_fragment() + + let roof_h = h.size * 0.55 + draw_prop(h.pos + float3(0.0, 0.0, body_h + roof_h * 0.45), + float3(h.size * 1.05, h.size * 1.18, roof_h), roof_color, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) + geo_prism |> draw_geometry_fragment() + + draw_prop(h.pos + float3(h.size * 0.42, h.size * 0.3, body_h + roof_h * 0.9), + float3(h.size * 0.12, h.size * 0.12, roof_h * 0.6), roof_color * 0.7, + float4(0.0, 0.0, 0.0, 1.0), MAT_MATTE) + geo_cube |> draw_geometry_fragment() + } +} + +// Bonuses. +// +// A pickup has to look worth flying across the channel for, so it is built in +// layers like the explosions are: a machined metal canister that takes the +// environment reflection, a hot emissive core, and -- in the effects pass -- a +// halo ring and a pool of light on the water under it. Each type also gets its +// own silhouette, so what you are collecting is readable before the colour is. + +def private bonus_pulse(game_time : float; phase : float) : float { + return 0.88 + 0.12 * sin(game_time * 4.0 + phase) +} + +def private bonus_body_rot(game_time : float; phase : float) : float4 { + return quat_mul(quat_z_rot(game_time * BONUS_SPIN_SPEED + phase), quat_y_rot(PI * 0.5)) +} + +def render_bonuses(game_time : float) { + query() $(b : BonusPickup) { + let bob = sin(game_time * BONUS_BOB_SPEED + b.bob_phase) * BONUS_BOB_AMPLITUDE + let p = b.pos + float3(0.0, 0.0, 0.72 + bob) + let color = bonus_color(b.bonus_type) + let rot = bonus_body_rot(game_time, b.bob_phase) + let pulse = bonus_pulse(game_time, b.bob_phase) + + // Machined shell: chrome, so it picks up the sky and the sun rather than + // sitting flat the way the old painted cylinder did. + let shell = float3(0.72, 0.76, 0.80) + draw_prop(p, float3(0.36, 0.36, 0.60) * pulse, shell, rot, MAT_METAL, REFLECT_CHROME) + geo_cylinder |> draw_geometry_fragment() + + // End caps in the pickup's own colour: the type reads off the ends even + // when the shell is busy reflecting. + for (side in fixed_array(-1.0, 1.0)) { + let cap = p + quat_mul_vec(rot, float3(0.0, 0.0, side * 0.60)) * pulse + draw_prop(cap, float3(0.40, 0.40, 0.09) * pulse, color * 1.4, rot, MAT_GLOW) + geo_cylinder |> draw_geometry_fragment() + } + + // Type badge: a distinct shape bolted to the middle of the shell. + if (b.bonus_type == BonusType.multishot) { + // Three barrels. + for (i in range(3)) { + let a = game_time * BONUS_SPIN_SPEED + b.bob_phase + float(i) * (2.0 * PI / 3.0) + let off = float3(cos(a), sin(a), 0.0) * 0.27 + draw_prop(p + off, float3(0.10, 0.10, 0.42) * pulse, color, rot, MAT_METAL, REFLECT_METAL) + geo_cylinder |> draw_geometry_fragment() + } + } elif (b.bonus_type == BonusType.fastshot) { + // A forward chevron. + draw_prop(p + float3(0.0, 0.40, 0.0), float3(0.27, 0.27, 0.33) * pulse, + color, quat_x_rot(-PI * 0.5), MAT_GLOW) + geo_cone |> draw_geometry_fragment() + } elif (b.bonus_type == BonusType.life) { + // A cross. + draw_prop(p, float3(0.46, 0.13, 0.13) * pulse, color, rot, MAT_GLOW) + geo_cube |> draw_geometry_fragment() + draw_prop(p, float3(0.13, 0.13, 0.46) * pulse, color, rot, MAT_GLOW) + geo_cube |> draw_geometry_fragment() + } else { + // Fuel: a banded collar. + draw_prop(p, float3(0.41, 0.41, 0.16) * pulse, color, rot, MAT_GLOW) + geo_cylinder |> draw_geometry_fragment() + } + + // The core, always hot. + draw_prop(p, float3(0.22) * pulse, color * 2.2, rot, MAT_GLOW) + geo_sphere |> draw_geometry_fragment() + } +} + +// Additive layers for the pickups: a halo ring that spins on its own axis, and a +// pool of light where the pickup is reflected on the water. Drawn with the +// effects, after the opaque scene. +def render_bonus_fx(game_time : float) { + // Light pool on the water first -- a soft billboard laid flat. + use_puff_program() + query() $(b : BonusPickup) { + let color = bonus_color(b.bonus_type) + let pulse = bonus_pulse(game_time, b.bob_phase) + draw_puff(float3(b.pos.x, b.pos.y, 0.05), 1.7 * pulse, color, 0.38, 1.8, + game_time * 0.3 + b.bob_phase, b.bob_phase) + geo_disc |> draw_geometry_fragment() + } + + glUseProgram(ring_program) + active_program = ring_program + query() $(b : BonusPickup) { + let bob = sin(game_time * BONUS_BOB_SPEED + b.bob_phase) * BONUS_BOB_AMPLITUDE + let p = b.pos + float3(0.0, 0.0, 0.72 + bob) + let color = bonus_color(b.bonus_type) + let pulse = bonus_pulse(game_time, b.bob_phase) + // Two rings on different axes, counter-rotating: reads as a containment + // field rather than as a decal. + let tilt_a = quat_mul(quat_z_rot(game_time * 1.6 + b.bob_phase), quat_x_rot(PI * 0.42)) + let tilt_b = quat_mul(quat_z_rot(-game_time * 1.1 - b.bob_phase), quat_y_rot(PI * 0.5)) + for (rot, radius in fixed_array(tilt_a, tilt_b), fixed_array(0.92, 0.74)) { + v_model = compose(p, rot, float3(radius * pulse, radius * pulse, 1.0)) + f_Color = float4(color * 1.7, 0.80) + f_Material = float4(0.13, 0.0, 1.9, 0.0) + vs_ring_bind_uniform(active_program) + fs_ring_bind_uniform(active_program) + geo_disc |> draw_geometry_fragment() + } + } + use_unlit_program() +} + +// --- Transparent scene --- + +// Explosion layers draw in three passes so they composite correctly: smoke and +// debris alpha-blended first (they occlude), then everything hot additively on +// top, which is also what feeds the bloom. + +def private draw_smoke(game_time : float) { + use_puff_program() + query() $(p : Particle) { + if (p.kind != ParticleKind.smoke) { return ; } + let life = p.lifetime / p.max_life + // Smoke expands as it ages and fades from the far end of its life, so a + // puff thins out instead of blinking off. + let grow = 1.0 + (1.0 - life) * 2.2 + let alpha = saturate(life * 1.5) * 0.5 + let spin = p.spin_phase + p.spin_speed * (1.0 - life) + game_time * 0.08 + draw_puff(p.pos, p.size * grow, p.color, alpha, 0.0, spin, p.spin_phase) + geo_disc |> draw_geometry_fragment() + } + use_unlit_program() +} + +def private draw_hot_layers() { + // Flash and fireballs are soft billboards; only the streaking embers below + // want real geometry. + use_puff_program() + + // Flash: one frame-and-a-bit of blowout at the instant of the hit. + query() $(p : Particle) { + if (p.kind != ParticleKind.flash) { return ; } + let life = p.lifetime / p.max_life + let grow = 0.5 + (1.0 - life) * 0.9 + draw_puff(p.pos, p.size * grow, p.color, life * life * 0.9, 4.5 * life, 0.0, 0.0) + geo_disc |> draw_geometry_fragment() + } + + // Fireballs: expand and cool white -> orange -> deep red. + query() $(p : Particle) { + if (p.kind != ParticleKind.fire) { return ; } + let life = p.lifetime / p.max_life + let age = 1.0 - life + let grow = 0.6 + age * 1.05 + var tint = lerp(float3(1.0, 0.48, 0.09), float3(1.0, 0.97, 0.86), saturate(life * 2.6 - 1.6)) + tint = lerp(float3(0.34, 0.045, 0.015), tint, saturate(life * 1.9)) + draw_puff(p.pos, p.size * grow, tint, saturate(life * 1.4), 4.2 * life * life, + p.spin_phase, p.spin_phase * 0.7) + geo_disc |> draw_geometry_fragment() + } + + use_unlit_program() + + // Embers: small, bright, and they streak along their own velocity. + query() $(p : Particle) { + if (p.kind != ParticleKind.ember && p.kind != ParticleKind.trail) { return ; } + let life = p.lifetime / p.max_life + let speed = length(p.vel) + let tint = lerp(float3(0.9, 0.18, 0.04), p.color, saturate(life * 1.4)) + if (p.kind == ParticleKind.ember && speed > 0.5) { + let dir = p.vel / speed + let stretch = clamp(speed * 0.035, 1.0, 4.5) + draw_unlit(p.pos, float3(p.size, p.size, p.size * stretch), tint, + saturate(life * 2.0), quat_align_z(dir), 3.4 * life) + geo_cylinder |> draw_geometry_fragment() + } else { + draw_unlit(p.pos, float3(max(p.size, BULLET_SIZE * 0.8)), tint, + saturate(life * 2.0), float4(0.0, 0.0, 0.0, 1.0), 2.2 * life) + geo_sphere |> draw_geometry_fragment() + } + } +} + +def private draw_shockwaves() { + glUseProgram(ring_program) + active_program = ring_program + query() $(p : Particle) { + if (p.kind != ParticleKind.shock) { return ; } + let life = p.lifetime / p.max_life + let age = 1.0 - life + // The front races out fast and early, then eases; the band narrows as + // it goes, which is what makes it read as travelling rather than growing. + let radius = p.size * (0.10 + sqrt(age) * 0.90) + let width = lerp(0.20, 0.045, age) + v_model = compose(p.pos, float4(0.0, 0.0, 0.0, 1.0), float3(radius, radius, 1.0)) + f_Color = float4(p.color, life * life * 0.55) + f_Material = float4(width, 0.0, 1.1 * life, 0.0) + vs_ring_bind_uniform(active_program) + fs_ring_bind_uniform(active_program) + geo_disc |> draw_geometry_fragment() + } +} + +def render_projectiles(game_time : float) { + use_unlit_program() + draw_smoke(game_time) + + // Player tracers: a bright core with a longer, dimmer streak behind it. + query() $(b : PlayerBullet) { + let dir = normalize(b.vel) + let rot = quat_align_z(dir) + draw_unlit(b.pos, float3(BULLET_SIZE * 0.9, BULLET_SIZE * 0.9, BULLET_SIZE * 3.6), + float3(0.35, 1.0, 0.92), 1.0, rot, 2.4) + geo_cylinder |> draw_geometry_fragment() + draw_unlit(b.pos - dir * BULLET_SIZE * 4.0, + float3(BULLET_SIZE * 0.55, BULLET_SIZE * 0.55, BULLET_SIZE * 5.0), + float3(0.18, 0.75, 0.85), 0.35, rot, 1.0) + geo_cylinder |> draw_geometry_fragment() + } + + query() $(b : EnemyBullet) { + let flicker = 0.8 + 0.2 * sin(game_time * 30.0 + b.pos.x) + if (b.bullet_type == 0) { + draw_unlit(b.pos, float3(ENEMY_BULLET_SIZE * 0.85), float3(1.0, 0.80, 0.28), + 1.0, float4(0.0, 0.0, 0.0, 1.0), 1.6 * flicker) + geo_sphere |> draw_geometry_fragment() + } else { + let dir = normalize(b.vel) + let rot = quat_align_z(dir) + let tint = (b.bullet_type == 1 ? float3(1.0, 0.16, 0.12) : float3(1.0, 0.55, 0.10)) + draw_unlit(b.pos, float3(ENEMY_BULLET_SIZE * 0.5, ENEMY_BULLET_SIZE * 0.5, + ENEMY_BULLET_SIZE * 2.6), tint, 1.0, rot, 1.6 * flicker) + geo_cylinder |> draw_geometry_fragment() + } + } + + // Additive from here on: fire adds light, it does not occlude. + glBlendFunc(GL_SRC_ALPHA, uint(GL_ONE)) + use_unlit_program() + draw_hot_layers() + draw_shockwaves() + render_bonus_fx(game_time) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) +} + +def private draw_disc(center : float3; radius : float; rot : float4; color : float3; alpha, phase : float) { + v_model = compose(center, rot, float3(radius, radius, radius)) + f_Color = float4(color, alpha) + f_Material = float4(1.0, 0.0, phase, 0.0) + vs_rotor_bind_uniform(active_program) + fs_rotor_bind_uniform(active_program) + geo_disc |> draw_geometry_fragment() +} + +// Rotor discs. One alpha-blended disc per aircraft, with the blade phase driven +// through the material's emissive slot so the shader can smear it. +def render_rotors(game_time : float) { + glDisable(GL_CULL_FACE) + glUseProgram(rotor_program) + active_program = rotor_program + + if (player_respawn_timer <= PLAYER_RESPAWN_BLINK) { + let blinking = player_respawn_timer > 0.0 + if (!blinking || int(game_time * 16.0) % 2 != 0) { + let orient = player_orientation() + let up_offset = PLAYER_SIZE * 1.85 * 0.70 + draw_disc(player_pos + float3(0.0, 0.0, up_offset), PLAYER_ROTOR_SIZE * 1.05, + orient, float3(0.82, 0.88, 0.96), 0.55, game_time) + // Tail rotor, spinning in the vertical plane on the fin. + draw_disc(player_pos + float3(-PLAYER_SIZE * 0.20, -PLAYER_SIZE * 2.10, up_offset * 0.62), + PLAYER_ROTOR_SIZE * 0.34, quat_mul(orient, quat_y_rot(PI * 0.5)), + float3(0.78, 0.84, 0.92), 0.45, game_time * 2.4) + } + } + + query() $(h : EnemyHelicopter) { + let up_offset = ENEMY_HELI_SIZE * 1.7 * 0.70 + draw_disc(h.pos + float3(0.0, 0.0, up_offset), PLAYER_ROTOR_SIZE * 0.92, + float4(0.0, 0.0, 0.0, 1.0), float3(0.85, 0.42, 0.32), 0.5, + game_time + h.hover_phase) + } + + glEnable(GL_CULL_FACE) +} + +// Explosion debris draws with the scene, not with the effects: it is solid +// torn plating, so it wants the lit prop program, real shading and the metal +// reflection -- as an unlit alpha quad it read as flying paper. +def render_debris() { + if (empty(geo_shards)) { + return + } + use_prop_program() + query() $(p : Particle) { + if (p.kind != ParticleKind.debris) { return ; } + let life = p.lifetime / p.max_life + let tumble = quat_mul(quat_y_rot(p.spin_phase + p.spin_speed * (1.0 - life)), + quat_x_rot(p.spin_phase * 1.7 + p.spin_speed * 0.6 * (1.0 - life))) + // Chunks glow briefly at the flash, then go cold. Shrinking over the + // last of their life hides the pop when they are culled. + let heat = saturate((life - 0.82) * 5.5) + let tint = lerp(p.color * 0.7, float3(1.0, 0.62, 0.24), heat) + let fade = saturate(life * 4.0) + draw_prop(p.pos, float3(p.size * fade), tint, tumble, + float4(0.45, 0.55, heat * 1.4, 0.30), REFLECT_METAL) + let shard_idx = int(p.spin_phase * 1.9) % length(geo_shards) + geo_shards[shard_idx] |> draw_geometry_fragment() + } +} + +def render_all(game_time : float) { + render_player(game_time) + render_enemies(game_time) + render_obstacles(game_time) + render_debris() +} + +def render_effects(game_time : float) { + render_projectiles(game_time) + render_rotors(game_time) +} diff --git a/web/examples/ui/samples/examples/river_run/hud.das b/web/examples/ui/samples/examples/river_run/hud.das new file mode 100644 index 0000000000..b89a90a4ae --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/hud.das @@ -0,0 +1,191 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require rr_globals public + +// Text layer. Everything is positioned in DESIGN pixels against a 1280x720 +// reference and scaled by hud_scale(), so the type keeps its proportions on a +// HiDPI framebuffer instead of shrinking to half size. +// +// Every string is drawn with a dark outline first. Over bright water or a lit +// sky, plain glyphs lose their edges entirely; the outline costs four extra +// quad draws and makes the HUD readable on any background. + +let TEXT_TITLE = 1.65 +let TEXT_HEAD = 0.90 +let TEXT_BODY = 0.62 +let TEXT_SMALL = 0.50 + +let OUTLINE_COLOR = float3(0.02, 0.03, 0.05) + +def private text_mvp(x, y, scale : float) : float4x4 { + let projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -1.0, 1.0) + let model = compose(float3(x, y, 0.0), float4(0.0, 0.0, 0.0, 1.0), float3(scale, scale, 1.0)) + return projection * model +} + +def private text_width(quads : array; scale : float) : float { + let dim = quads_dim(quads) + return (dim.vmax.x - dim.vmin.x) * scale +} + +// Draw one string at a design-space position with an outline. `scale` is a +// multiplier on the design text size; hud_scale() is applied on top. +def draw_text(text : string; x, y : float; size : float = TEXT_BODY; + tint : float3 = float3(1.0); outline : bool = true) { + if (hud_font == null) { + return + } + var quads <- (*hud_font) |> create_quads(text) + let s = hud_scale() * size + let px = x * hud_scale() + let py = y * hud_scale() + if (outline) { + let o = max(hud_scale() * size * 1.1, 1.0) + for (d in fixed_array(float2(-1.0, 0.0), float2(1.0, 0.0), float2(0.0, -1.0), + float2(0.0, 1.0), float2(-0.7, -0.7), float2(0.7, 0.7))) { + (*hud_font) |> draw_quads(quads, text_mvp(px + d.x * o, py + d.y * o, s), OUTLINE_COLOR) + } + } + (*hud_font) |> draw_quads(quads, text_mvp(px, py, s), tint) + delete quads +} + +def draw_text_centered(text : string; cx, y : float; size : float = TEXT_BODY; + tint : float3 = float3(1.0)) { + if (hud_font == null) { + return + } + var quads <- (*hud_font) |> create_quads(text) + let w = text_width(quads, size) + delete quads + draw_text(text, cx - w * 0.5, y, size, tint) +} + +def private design_w() : float { + return float(display_w) / hud_scale() +} + +def private design_h() : float { + return float(display_h) / hud_scale() +} + +// A dimming plate behind a full-screen message, so titles never fight the +// scene behind them. +def private draw_scrim(alpha : float) { + use_unlit_program() + v_projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -100.0, 100.0) + v_view = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glDisable(GL_DEPTH_TEST) + draw_unlit( + float3(float(display_w) * 0.5, float(display_h) * 0.5, 0.0), + float3(float(display_w) * 0.5, float(display_h) * 0.5, 1.0), + float3(0.01, 0.015, 0.03), alpha + ) + geo_cube |> draw_geometry_fragment() +} + +def private draw_menu() { + let cx = design_w() * 0.5 + let cy = design_h() * 0.5 + draw_scrim(0.55) + draw_text_centered("RIVER RUN", cx, cy - 150.0, TEXT_TITLE, float3(0.55, 0.92, 1.0)) + draw_text_centered("fly the canyon · shoot what shoots back · do not run dry", + cx, cy - 92.0, TEXT_SMALL, float3(0.62, 0.70, 0.78)) + + draw_text_centered("ARROWS / A D steer", cx, cy - 30.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + draw_text_centered("W S throttle", cx, cy + 2.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + draw_text_centered("SPACE fire", cx, cy + 34.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + draw_text_centered("ESC pause", cx, cy + 66.0, TEXT_BODY, float3(0.86, 0.90, 0.94)) + + let blink = 0.55 + 0.45 * abs(sin(get_uptime() * 2.6)) + draw_text_centered("PRESS SPACE TO START", cx, cy + 128.0, TEXT_HEAD, + float3(1.0, 0.92, 0.45) * blink) +} + +def private draw_playing_readout() { + let w = design_w() + // Score sits inboard of the top-right life panel and clear of the progress + // rail, which is what the old centred readout kept colliding with. + draw_text("SCORE", 28.0, 32.0, TEXT_SMALL, float3(0.52, 0.62, 0.72)) + draw_text("{score}", 28.0, 62.0, TEXT_HEAD, float3(1.0, 1.0, 1.0)) + + let display_section = min(current_section + 1, MAX_SECTIONS) + draw_text_centered("SECTION {display_section} / {MAX_SECTIONS}", w * 0.5, 30.0, + TEXT_SMALL, float3(0.60, 0.72, 0.80)) + draw_text_centered("{env_now.name}", w * 0.5, 54.0, TEXT_BODY, float3(0.62, 0.86, 0.78)) +} + +def private draw_section_banner() { + if (section_banner_timer <= 0.0) { + return + } + let cx = design_w() * 0.5 + let cy = design_h() * 0.42 + // Fade in over the first quarter second, hold, then fade out. + let t = section_banner_timer / WAVE_BANNER_DURATION + let alpha = min(min((1.0 - t) * 6.0, 1.0), min(t * 2.2, 1.0)) + let display_section = min(current_section + 1, MAX_SECTIONS) + draw_text_centered("SECTION {display_section}", cx, cy, TEXT_TITLE * 0.75, + float3(1.0, 0.94, 0.55) * alpha) + draw_text_centered("{env_now.name}", cx, cy + 46.0, TEXT_HEAD, + float3(0.70, 0.88, 0.95) * alpha) +} + +def private draw_overlay_card(title : string; title_color : float3; sub, hint : string) { + let cx = design_w() * 0.5 + let cy = design_h() * 0.5 + draw_scrim(0.62) + draw_text_centered(title, cx, cy - 70.0, TEXT_TITLE, title_color) + if (sub != "") { + draw_text_centered(sub, cx, cy + 6.0, TEXT_HEAD, float3(0.92, 0.95, 1.0)) + } + let blink = 0.55 + 0.45 * abs(sin(get_uptime() * 2.6)) + draw_text_centered(hint, cx, cy + 62.0, TEXT_BODY, float3(0.80, 0.86, 0.92) * blink) +} + +// Pause deliberately does NOT dim the scene. Pausing is how you stop and look +// at something -- a dimming scrim over the whole frame hides the very thing you +// paused to inspect. Game over and the win screen keep their scrim, because +// there the message IS what you are meant to be reading. +def private draw_pause_banner() { + let cx = design_w() * 0.5 + let blink = 0.6 + 0.4 * abs(sin(get_uptime() * 2.6)) + draw_text_centered("PAUSED", cx, design_h() * 0.5 - 18.0, TEXT_HEAD, + float3(1.0, 0.88, 0.35) * blink) + draw_text_centered("ESC to resume", cx, design_h() * 0.5 + 14.0, TEXT_SMALL, + float3(0.72, 0.78, 0.86)) +} + +def draw_hud() { + if (hud_font == null) { + return + } + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glDisable(GL_DEPTH_TEST) + + if (game_state == GameState.menu) { + draw_menu() + glEnable(GL_DEPTH_TEST) + return + } + + draw_playing_readout() + draw_section_banner() + + if (game_state == GameState.paused) { + draw_pause_banner() + } elif (game_state == GameState.game_over_state) { + draw_overlay_card("GAME OVER", float3(1.0, 0.32, 0.28), "SCORE {score}", + "PRESS SPACE TO RESTART") + } elif (game_state == GameState.win_state) { + draw_overlay_card("RIVER RUN COMPLETE", float3(0.45, 1.0, 0.58), "SCORE {score}", + "PRESS SPACE TO RESTART") + } + + glEnable(GL_DEPTH_TEST) +} diff --git a/web/examples/ui/samples/examples/river_run/hud3d.das b/web/examples/ui/samples/examples/river_run/hud3d.das new file mode 100644 index 0000000000..78dec7071f --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/hud3d.das @@ -0,0 +1,328 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require rr_globals public +require gameplay + +// The instrument layer: fuel gauge, throttle, life icons, powerup timers and +// the section progress bar, all drawn as flat 2D geometry in a screen-space +// orthographic pass on top of the tone-mapped frame. +// +// Every constant below is in DESIGN pixels against a 1280x720 reference and is +// multiplied by hud_scale() at draw time. That is the fix for the old HUD, +// which used raw framebuffer pixels and so came out half size on any HiDPI +// display. + +let PANEL_MARGIN = 26.0 +let PANEL_PAD = 12.0 + +let GAUGE_W = 240.0 +let GAUGE_H = 16.0 +let GAUGE_LABEL_H = 15.0 + +let THROTTLE_W = 240.0 +let THROTTLE_H = 8.0 + +let LIFE_ICON_R = 13.0 +let LIFE_ICON_GAP = 12.0 + +let PWR_ICON_R = 15.0 +let PWR_RING_R = 23.0 +let PWR_RING_TICKS = 16 +let PWR_ROW_H = 58.0 + +let PROGRESS_H = 3.0 + +let COLOR_PANEL = float3(0.03, 0.045, 0.075) +let COLOR_FRAME = float3(0.34, 0.46, 0.58) +let COLOR_DIM = float3(0.16, 0.21, 0.27) +let COLOR_ACCENT = float3(0.35, 0.86, 1.0) + +def hud_set_uniforms() { + v_projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -100.0, 100.0) + v_view = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) +} + +// A screen-space axis-aligned rectangle, given in design pixels. +def hud_rect(x, y, w, h : float; color : float3; alpha : float = 1.0; emissive : float = 0.0) { + let s = hud_scale() + draw_unlit( + float3((x + w * 0.5) * s, (y + h * 0.5) * s, 0.0), + float3(w * 0.5 * s, h * 0.5 * s, 1.0), + color, alpha, float4(0.0, 0.0, 0.0, 1.0), emissive + ) + geo_cube |> draw_geometry_fragment() +} + +def hud_disc(cx, cy, r : float; color : float3; alpha : float = 1.0; emissive : float = 0.0) { + let s = hud_scale() + draw_unlit( + float3(cx * s, cy * s, 0.0), float3(r * s, r * s, 1.0), + color, alpha, float4(0.0, 0.0, 0.0, 1.0), emissive + ) + geo_disc |> draw_geometry_fragment() +} + +// A framed panel: dark plate, a hairline border, and a bright inset on the top +// edge so the whole HUD reads as one instrument cluster rather than loose bars. +def hud_panel(x, y, w, h : float; alpha : float = 0.72) { + hud_rect(x, y, w, h, COLOR_PANEL, alpha) + hud_rect(x, y, w, 1.5, COLOR_FRAME, alpha * 0.9) + hud_rect(x, y + h - 1.5, w, 1.5, COLOR_FRAME, alpha * 0.5) + hud_rect(x, y, 1.5, h, COLOR_FRAME, alpha * 0.5) + hud_rect(x + w - 1.5, y, 1.5, h, COLOR_FRAME, alpha * 0.5) +} + +def design_width() : float { + return float(display_w) / hud_scale() +} + +def design_height() : float { + return float(display_h) / hud_scale() +} + +// --- Fuel + throttle cluster (bottom left) --- + +def private draw_fuel_gauge(x, y : float) { + let frac = clamp(player_fuel / PLAYER_FUEL_MAX, 0.0, 1.0) + let color = fuel_bar_color() + + hud_rect(x, y, GAUGE_W, GAUGE_H, COLOR_DIM, 0.9) + if (frac > 0.001) { + hud_rect(x + 1.5, y + 1.5, (GAUGE_W - 3.0) * frac, GAUGE_H - 3.0, color, 1.0, 0.55) + // A brighter cap at the leading edge gives the bar a readable head as + // it drains, which a flat fill does not. + let head_x = x + 1.5 + max((GAUGE_W - 3.0) * frac - 3.0, 0.0) + hud_rect(head_x, y + 1.5, 3.0, GAUGE_H - 3.0, color * 1.6 + float3(0.25), 1.0, 1.4) + } + // Quarter ticks, so the pilot can read "half tank" without counting pixels. + for (i in range(1, 4)) { + let tx = x + GAUGE_W * float(i) * 0.25 + hud_rect(tx - 0.75, y, 1.5, GAUGE_H, COLOR_PANEL, 0.85) + } +} + +def private draw_throttle(x, y : float) { + let speed_max = PLAYER_FWD_SPEED_MAX * section_speed_mult() + let den = max(speed_max - PLAYER_FWD_SPEED_MIN, 0.001) + let frac = clamp((player_fwd_speed - PLAYER_FWD_SPEED_MIN) / den, 0.0, 1.0) + hud_rect(x, y, THROTTLE_W, THROTTLE_H, COLOR_DIM, 0.9) + if (frac > 0.001) { + hud_rect(x + 1.0, y + 1.0, (THROTTLE_W - 2.0) * frac, THROTTLE_H - 2.0, + lerp(COLOR_ACCENT, float3(1.0, 0.85, 0.35), frac), 1.0, 0.4) + } +} + +def draw_status_cluster() { + let base_y = design_height() - PANEL_MARGIN - (GAUGE_LABEL_H + GAUGE_H + THROTTLE_H + PANEL_PAD * 2.0 + 6.0) + let panel_w = GAUGE_W + PANEL_PAD * 2.0 + let panel_h = GAUGE_LABEL_H + GAUGE_H + THROTTLE_H + PANEL_PAD * 2.0 + 6.0 + hud_panel(PANEL_MARGIN, base_y, panel_w, panel_h) + + let inner_x = PANEL_MARGIN + PANEL_PAD + draw_fuel_gauge(inner_x, base_y + PANEL_PAD + GAUGE_LABEL_H) + draw_throttle(inner_x, base_y + PANEL_PAD + GAUGE_LABEL_H + GAUGE_H + 6.0) +} + +// --- Lives (top right) --- +// +// The life counter draws the player's own silhouette rather than an abstract +// pip, so the icon and the thing it stands for match. + +def draw_lives() { + if (player_lives <= 0) { + return + } + let s = hud_scale() + let step = LIFE_ICON_R * 2.0 + LIFE_ICON_GAP + let top = PANEL_MARGIN + LIFE_ICON_R + 4.0 + let panel_w = float(player_lives) * step + PANEL_PAD * 2.0 - LIFE_ICON_GAP + hud_panel(design_width() - PANEL_MARGIN - panel_w, PANEL_MARGIN, + panel_w, LIFE_ICON_R * 2.0 + PANEL_PAD * 1.5) + + for (i in range(player_lives)) { + let cx = design_width() - PANEL_MARGIN - PANEL_PAD - LIFE_ICON_R - float(i) * step + hud_disc(cx, top, LIFE_ICON_R + 2.0, COLOR_FRAME, 0.35) + // Nose-up plan view of the helicopter, scaled to the icon radius. + draw_prop( + float3(cx * s, top * s, 0.0), + float3(LIFE_ICON_R * 0.92 * s), + float3(1.0), + quat_mul(quat_z_rot(PI), quat_x_rot(-PI * 0.5)), + MAT_HULL + ) + geo_heli |> draw_geometry_fragment() + } +} + +// --- Powerup timers (left, under the status cluster) --- + +def private draw_powerup(cx, cy : float; t : BonusType; timer, max_timer : float) { + let color = bonus_color(t) + let frac = clamp(timer / max(max_timer, 0.001), 0.0, 1.0) + + hud_disc(cx, cy, PWR_ICON_R, COLOR_PANEL, 0.85) + hud_disc(cx, cy, PWR_ICON_R - 3.0, color, 0.95, 0.7) + + // A ring of ticks reading counter-clockwise from the top as the timer runs + // down -- readable at a glance without a number. + let lit = int(frac * float(PWR_RING_TICKS) + 0.999) + for (i in range(PWR_RING_TICKS)) { + let angle = float(i) * (2.0 * PI / float(PWR_RING_TICKS)) - PI * 0.5 + let tx = cx + cos(angle) * PWR_RING_R + let ty = cy + sin(angle) * PWR_RING_R + let on = i < lit + hud_disc(tx, ty, (on ? 2.6 : 1.8), (on ? color : COLOR_DIM), 1.0, (on ? 0.8 : 0.0)) + } + + // The last two seconds flash, so an expiring powerup is impossible to miss. + if (timer < 2.0 && int(get_uptime() * 8.0) % 2 == 0) { + hud_disc(cx, cy, PWR_RING_R + 4.0, color, 0.22, 0.5) + } +} + +def draw_powerups() { + let base_y = design_height() - PANEL_MARGIN - 150.0 + var row = 0 + if (player_multishot_timer > 0.0) { + draw_powerup(PANEL_MARGIN + PWR_RING_R + 6.0, base_y - float(row) * PWR_ROW_H, + BonusType.multishot, player_multishot_timer, BONUS_MULTISHOT_TIME) + row++ + } + if (player_fastshot_timer > 0.0) { + draw_powerup(PANEL_MARGIN + PWR_RING_R + 6.0, base_y - float(row) * PWR_ROW_H, + BonusType.fastshot, player_fastshot_timer, BONUS_FASTSHOT_TIME) + row++ + } +} + +// --- Section progress (a thin rail across the top of the screen) --- + +def draw_progress_rail() { + let w = design_width() + let frac = clamp(section_dist / SECTION_LENGTH, 0.0, 1.0) + hud_rect(0.0, 0.0, w, PROGRESS_H, COLOR_PANEL, 0.45) + hud_rect(0.0, 0.0, w * frac, PROGRESS_H, COLOR_ACCENT, 0.95, 0.6) + + // Section boundaries as notches, so the rail also shows how far into the + // run you are overall. + for (i in range(1, MAX_SECTIONS)) { + let tx = w * float(i) / float(MAX_SECTIONS) + let passed = i <= current_section + hud_rect(tx - 1.0, 0.0, 2.0, PROGRESS_H + 2.0, + (passed ? COLOR_ACCENT : COLOR_DIM), 0.75) + } +} + +// --- Low fuel warning bar --- + +def draw_low_fuel_warning() { + if (player_fuel >= LOW_FUEL_THRESHOLD || game_state != GameState.playing) { + return + } + let pulse = 0.35 + 0.35 * abs(sin(get_uptime() * 6.0)) + let h = design_height() + let w = design_width() + // A vignette-like band top and bottom rather than a full-screen wash, so + // the warning never obscures the water the player is threading. + hud_rect(0.0, 0.0, w, 26.0, float3(1.0, 0.15, 0.1), pulse * 0.5, 0.6) + hud_rect(0.0, h - 26.0, w, 26.0, float3(1.0, 0.15, 0.1), pulse * 0.5, 0.6) +} + +// The scene shaders fog by view distance, and in the screen-space ortho pass +// "view distance" is a pixel coordinate -- hundreds of units, which fogs every +// panel and scrim to solid haze. The whole overlay therefore runs with fog +// switched off; the caller owns the save/restore because the text layer draws +// its own geometry too. +def begin_hud_overlay() : float3 { + let saved_fog = f_FogParams + f_FogParams = float3(0.0, 0.0, 0.0) + return saved_fog +} + +def end_hud_overlay(saved_fog : float3) { + f_FogParams = saved_fog +} + +def draw_hud_3d(_game_time : float) { + let saved_proj = v_projection + let saved_view = v_view + hud_set_uniforms() + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + if (game_state != GameState.menu) { + use_unlit_program() + draw_progress_rail() + draw_status_cluster() + draw_powerups() + draw_low_fuel_warning() + + // Life icons are lit geometry under a fixed palette, so the counter + // stays legible at night and in the ash channel. + if (player_lives > 0) { + use_prop_program() + let saved_light = push_hud_light() + player_hud_palette() + draw_lives() + clear_model_palette() + pop_hud_light(saved_light) + } + } + + // Culling and depth stay off: the text layer draws next, and its quads have + // no reliable winding. + glEnable(GL_DEPTH_TEST) + v_projection = saved_proj + v_view = saved_view +} + +// A fixed, sunlit palette for the HUD helicopters so they stay legible at +// night and in the ash channel. +def private player_hud_palette() { + set_model_palette( + float3(0.78, 0.86, 0.94), + float3(0.20, 0.38, 0.48), + float3(0.58, 0.62, 0.66), + float3(0.12, 0.14, 0.16) + ) +} + +// The life icons are lit geometry drawn in screen space, where the world sun +// direction means nothing -- at night it left them unlit and unreadable. Swap +// in a fixed three-quarter key for the icon draw and put the scene's lighting +// back afterwards. +struct SavedLight { + sun_dir : float3 + sun_color : float3 + sky_color : float3 + horizon_color : float3 + bounce_color : float3 +} + +def private push_hud_light() : SavedLight { + let saved = SavedLight( + sun_dir = f_SunDir, + sun_color = f_SunColor, + sky_color = f_SkyColor, + horizon_color = f_HorizonColor, + bounce_color = f_BounceColor + ) + f_SunDir = normalize(float3(-0.45, -0.55, 0.70)) + f_SunColor = float3(1.15, 1.18, 1.25) + f_SkyColor = float3(0.26, 0.30, 0.38) + f_HorizonColor = float3(0.30, 0.34, 0.40) + f_BounceColor = float3(0.10, 0.12, 0.16) + return saved +} + +def private pop_hud_light(saved : SavedLight) { + f_SunDir = saved.sun_dir + f_SunColor = saved.sun_color + f_SkyColor = saved.sky_color + f_HorizonColor = saved.horizon_color + f_BounceColor = saved.bounce_color +} diff --git a/web/examples/ui/samples/examples/river_run/live_stub.das b/web/examples/ui/samples/examples/river_run/live_stub.das new file mode 100644 index 0000000000..0e4a26720b --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/live_stub.das @@ -0,0 +1,137 @@ +options gen2 +options no_unused_block_arguments = false +options no_unused_function_arguments = false +options indenting = 4 + +module live_stub shared public + +//! Playground shim for daslang-live apps. +//! +//! Lets a daslang-live game/app run under the web playground's +//! init/update/shutdown browser loop with NO live-reload host, NO REST API, +//! and NO jobque. Provides the window + frame-clock API the apps call +//! (live_create_window / live_begin_frame / live_end_frame / +//! live_get_framebuffer_size / live_destroy_window / live_window), the +//! live_host clock/exit helpers (get_dt / get_uptime / is_reload / +//! exit_requested / request_exit / maybe_collect_gc), and a no-op +//! [live_command] so REST command functions still parse (they are dead code +//! on the web). `@live` needs no shim — it is inert free-form metadata when +//! live_vars is not required. +//! +//! Swap an app's `require live/...` + `require live_host` block for a single +//! `require live_stub` (same directory) and it runs in the playground. + +require glfw/glfw_boost +require opengl/opengl_cache +require daslib/ast_boost +require math + +var public live_window : GLFWwindow? + +var private g_exit_requested = false +var private g_start_ticks = ref_time_ticks() +var private g_uptime = 0.0 +var private g_dt = 0.0 +var private g_last_gc_bytes = 0ul + +def public live_create_window(title : string; width, height : int) : GLFWwindow? { + return live_window if (live_window != null) + if (glfwInit() == 0) { + panic("live_stub: can't init glfw") + } + glfwInitOpenGL(3, 3) + live_window = glfwCreateWindow(width, height, title, null, null) + if (live_window == null) { + panic("live_stub: can't create window") + } + glfwMakeContextCurrent(live_window) + // No GLFW_SAMPLES hint / glEnable(GL_MULTISAMPLE): GL_MULTISAMPLE is an + // invalid enum under WebGL2 (MSAA there is per-FBO, not a global toggle). + // Mirrors the proven gl_* playground window path; opengl_cache the games need. + init_opengl_cache() + g_start_ticks = ref_time_ticks() + g_uptime = 0.0 + g_dt = 0.0 + return live_window +} + +def public live_destroy_window() { + if (live_window != null) { + glfwDestroyWindow(live_window) + live_window = null + glfwTerminate() + } +} + +def public live_begin_frame() : bool { + return false if (live_window == null) + glfwPollEvents() + if (glfwWindowShouldClose(live_window) != 0) { + g_exit_requested = true + return false + } + let now = float(double(get_time_usec(g_start_ticks)) / 1000000.0lf) + g_dt = clamp(now - g_uptime, 0.0, 0.1) // clamp the first frame / tab-stall spikes + g_uptime = now + return true +} + +def public live_end_frame() { + if (live_window != null) { + glfwSwapBuffers(live_window) + } + maybe_collect_gc() // the browser loop does not GC for us; do it per frame +} + +def public live_get_framebuffer_size(var width, height : int&) { + if (live_window != null) { + glfwGetFramebufferSize(live_window, width, height) + } else { + width = 0 + height = 0 + } +} + +def public get_dt() : float { + return g_dt +} + +def public get_uptime() : float { + return g_uptime +} + +def public is_reload() : bool { + return false +} + +def public exit_requested() : bool { + return g_exit_requested +} + +def public request_exit() { + g_exit_requested = true +} + +let private GC_GROWTH_BYTES = 4ul * 1024ul * 1024ul // collect once the live heap grows ~4 MB + +def public maybe_collect_gc() { + // The browser loop never collects for us, and glfw_live's free-ratio heuristic + // needs heap_total_allocated() (post-64-bit-sweep API). To stay portable we use + // a growth threshold on the live bytes only: collect when the combined live heap + // has grown past GC_GROWTH_BYTES since the last collect. Requires options gc + + // options persistent_heap on the program (heap_collect throws otherwise). + let used = heap_bytes_allocated() + string_heap_bytes_allocated() + if (used > g_last_gc_bytes + GC_GROWTH_BYTES) { + unsafe(heap_collect(true, false)) + g_last_gc_bytes = heap_bytes_allocated() + string_heap_bytes_allocated() + } +} + +// No-op [live_command]: the REST command functions stay defined (harmless dead +// code on the web) but register nothing. Mirrors live_commands' macro shape. +[function_macro(name="live_command")] +class LiveCommandStub : AstFunctionAnnotation { + def override apply(var func : FunctionPtr; var group : ModuleGroup; args : AnnotationArgumentList; var errors : das_string) : bool { + return true + } +} diff --git a/web/examples/ui/samples/examples/river_run/main.das b/web/examples/ui/samples/examples/river_run/main.das new file mode 100644 index 0000000000..cd4da88e15 --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/main.das @@ -0,0 +1,392 @@ +options gen2 +options persistent_heap +options gc + +// river run -- web playground port. rr_live (the REST command surface) is a +// development tool with no role here, so it is not part of this copy. + +require rr_globals public +require rr_audio +require river +require gameplay +require hud +require hud3d +require daslib/strings_convert + +// --- GL Setup --- + +def create_gl_objects() { + create_scene_programs() + create_postfx_programs() + + geo_sphere <- create_geometry_fragment <| gen_sphere(14, 10, false) + geo_cube <- create_geometry_fragment <| gen_cube() + geo_plane_xz <- create_geometry_fragment <| gen_plane(GenDirection.xz) + geo_cylinder <- create_geometry_fragment <| gen_cylinder(GenDirection.xy, 12) + geo_cone <- create_geometry_fragment <| gen_cone(GenDirection.xy, 12) + geo_prism <- create_geometry_fragment <| gen_prism(GenDirection.xy) + geo_disc <- create_geometry_fragment <| gen_disc(24) + geo_heli <- create_geometry_fragment <| gen_helicopter() + geo_gunboat <- create_geometry_fragment <| gen_gunboat() + geo_jet <- create_geometry_fragment <| gen_jet() + for (frag in geo_shards) { + finalize(frag) + } + geo_shards |> clear() + geo_shards |> reserve(SHARD_VARIANTS) + for (i in range(SHARD_VARIANTS)) { + geo_shards |> emplace(create_geometry_fragment(gen_shard_variant(i))) + } + + // Several pre-generated instances of each flora kind, so a crowded bank is + // not the same tree stamped forty times. + for (frag in geo_flora) { + finalize(frag) + } + geo_flora |> clear() + geo_flora |> reserve(FLORA_VARIANTS * FLORA_INSTANCES) + for (kind in range(FLORA_VARIANTS)) { + for (i in range(FLORA_INSTANCES)) { + geo_flora |> emplace(create_geometry_fragment(gen_flora(kind, kind * 31 + i * 7 + 1))) + } + } + + cache_ttf_objects() + hud_font = cache_font("{get_das_root()}/modules/dasStbImage/fonts/droidsansmono.ttf") +} + +// --- Camera --- + +var cam_eye = float3(0.0, 0.0, 10.0) +var cam_right = float3(1.0, 0.0, 0.0) +var cam_up = float3(0.0, 0.0, 1.0) +var cam_fwd = float3(0.0, 1.0, 0.0) +var cam_scale = float2(1.0, 1.0) + +let CAM_FOV_DEG = 50.0 +let Z_NEAR = 0.1 +let Z_FAR = 420.0 + +def update_camera(aspect : float) { + // Camera lags behind player X + cam_x += (player_pos.x - cam_x) * CAM_X_LAG + + // Pitch lerps with current player speed: slow → CAM_PITCH_SLOW_DEG, fast → CAM_PITCH_FAST_DEG. + let speed_max = PLAYER_FWD_SPEED_MAX * section_speed_mult() + let speed_den = max(speed_max - PLAYER_FWD_SPEED_MIN, 0.001) + let speed_t = clamp((player_fwd_speed - PLAYER_FWD_SPEED_MIN) / speed_den, 0.0, 1.0) + let pitch_deg = CAM_PITCH_SLOW_DEG + (CAM_PITCH_FAST_DEG - CAM_PITCH_SLOW_DEG) * speed_t + let cam_height = (CAM_BACK + CAM_LOOK_AHEAD) * tan(pitch_deg * PI / 180.0) + + let t = get_uptime() + let shake = float3(sin(t * 47.0), sin(t * 53.0), sin(t * 61.0)) * screen_shake_amount + + cam_eye = float3(cam_x, player_pos.y - CAM_BACK, cam_height) + shake + let center = float3(player_pos.x, player_pos.y + CAM_LOOK_AHEAD, 0.0) + let world_up = float3(0.0, 0.0, 1.0) + v_view = look_at_rh(cam_eye, center, world_up) + + // The sky pass is attributeless, so it rebuilds view rays from this basis + // rather than from an inverse view-projection. + cam_fwd = normalize(center - cam_eye) + cam_right = normalize(cross(cam_fwd, world_up)) + cam_up = cross(cam_right, cam_fwd) + let tan_half = tan(CAM_FOV_DEG * 0.5 * PI / 180.0) + cam_scale = float2(tan_half * aspect, tan_half) +} + +// --- Lighting environment --- +// +// One ortho cascade tracking the play area ahead of the player. The box is wide +// enough to hold both banks and long enough to cover everything the camera can +// see, which is all a game this shallow needs. + +let SHADOW_HALF_WIDTH = 34.0 +let SHADOW_AHEAD = 58.0 +let SHADOW_BEHIND = 22.0 +let SHADOW_DEPTH = 120.0 + +def build_light_matrix() { + let focus = float3(cam_x, player_pos.y + (SHADOW_AHEAD - SHADOW_BEHIND) * 0.5, 1.5) + let eye = focus + env_now.sun_dir * (SHADOW_DEPTH * 0.5) + let up = float3(0.0, 0.0, 1.0) + let half_len = (SHADOW_AHEAD + SHADOW_BEHIND) * 0.5 + let view = look_at_rh(eye, focus, up) + let proj = ortho_rh(-SHADOW_HALF_WIDTH, SHADOW_HALF_WIDTH, + -half_len, half_len, 0.1, SHADOW_DEPTH) + v_light_vp = proj * view +} + +def apply_environment(game_time : float) { + f_SunDir = env_now.sun_dir + f_SunColor = env_now.sun_color + f_SkyColor = env_now.sky_color + f_HorizonColor = env_now.horizon_color + f_BounceColor = env_now.bounce_color + f_FogColor = env_now.fog_color + f_FogParams = float3(env_now.fog_density, 0.018, 38.0) + f_NightAmount = env_now.night + f_CameraPos = cam_eye + f_GameTime = game_time + f_ZFar = Z_FAR + f_CamRight = cam_right + f_CamUp = cam_up + f_CamFwd = cam_fwd + f_CamScale = cam_scale + f_ShadowMap := shadow_tex + f_ShadowTexel = float2(1.0 / float(SHADOW_SIZE)) + f_PaletteOn = 0.0 +} + +// Where the sun lands on screen, for the shaft pass. Returns w <= 0 behind the +// camera, in which case the caller skips the whole scattering chain. +def sun_screen_uv() : float3 { + let sun_world = cam_eye + env_now.sun_dir * 900.0 + let clip = v_projection * (v_view * float4(sun_world, 1.0)) + if (clip.w <= 0.0) { + return float3(0.5, 0.5, 0.0) + } + let ndc = clip.xyz / clip.w + let uv = ndc.xy * 0.5 + float2(0.5) + // Let the shafts persist a little past the frame edge so the effect fades + // out as the sun leaves the view instead of popping off. + let on_screen = (uv.x > -0.45 && uv.x < 1.45 && uv.y > -0.45 && uv.y < 1.45 ? 1.0 : 0.0) + return float3(uv, on_screen) +} + +def build_post_settings(game_time : float) : PostSettings { + var cfg = PostSettings() + let sun_uv = sun_screen_uv() + cfg.exposure = env_now.exposure + cfg.bloom_strength = env_now.bloom + cfg.shaft_uv = sun_uv.xy + cfg.shaft_on_screen = sun_uv.z + cfg.shaft_strength = env_now.shafts + cfg.grade_tint = env_now.grade_tint + cfg.grade_amount = env_now.grade_amount + cfg.tan_half_fov = cam_scale + cfg.z_far = Z_FAR + cfg.time = game_time + // A hit briefly hot-grades the frame: more bloom, more grain, harder + // vignette. Cheap, and it sells the impact better than the shake alone. + let stress = saturate(screen_shake_amount * 0.9) + cfg.bloom_strength += stress * 0.5 + cfg.grain += stress * 0.05 + cfg.vignette_strength += stress * 0.25 + return cfg +} + +// --- State Transitions --- + +var prev_esc_pressed = false +var prev_space_pressed = false + +def handle_menu_input() { + let space = glfwGetKey(live_window, GLFW_KEY_SPACE) == GLFW_PRESS + let space_just = space && !prev_space_pressed + prev_space_pressed = space + + if (game_state == GameState.menu && space_just) { + reset_game() + } elif ((game_state == GameState.game_over_state || game_state == GameState.win_state) + && space_just && restart_input_lock <= 0.0) { + reset_game() + } +} + +def handle_pause_input() { + let esc = glfwGetKey(live_window, GLFW_KEY_ESCAPE) == GLFW_PRESS + let esc_just = esc && !prev_esc_pressed + prev_esc_pressed = esc + + if (!esc_just) { + return + } + if (game_state == GameState.playing) { + game_state = GameState.paused + } elif (game_state == GameState.paused) { + game_state = GameState.playing + } elif (game_state == GameState.menu) { + pass + } +} + +// --- Main Init / Update / Shutdown --- + +[export] +def init() { + live_create_window("River Run", 1280, 720) + create_gl_objects() + init_audio() + + if (!is_reload()) { + decs::restart() + commit() + game_state = GameState.menu + player_pos = float3(0.0, 0.0, PLAYER_Z) + player_lives = 3 + player_fuel = PLAYER_FUEL_MAX + score = 0 + current_section = 0 + cam_x = 0.0 + init_river() + } elif (empty(river_segments)) { + // A FULL reload resets every @live variable, so the river is gone even + // though is_reload() is true. Rebuild rather than run on an empty world. + init_river() + } + + // Neither of these survives a reload: the section environment lives in + // plain (non-@live) globals, and the geometry handles name GL objects in a + // context that has just been destroyed. Rebuilding both unconditionally is + // what keeps a reload from coming back to a black screen. + set_env_immediate(section_idx()) + rebuild_river_geometry() + river_dirty = false +} + +def simulate() { + // fx_freeze is the effect-inspection rig: it holds the whole world, not just + // the particles, because otherwise the player flies out of the blast's + // postcode between one inspection command and the next. + if (game_state != GameState.playing || fx_freeze) { + return + } + update_section() + update_player(false) + commit() + if (game_state == GameState.playing) { + update_enemies() + process_collisions() + commit() + } + if (game_state == GameState.playing) { + cull_far_entities() + advance_river() + if (river_dirty) { + rebuild_river_geometry() + river_dirty = false + } + } +} + +def render_frame(game_time : float) { + ensure_render_targets(display_w, display_h) + + build_light_matrix() + apply_environment(game_time) + + begin_shadow_pass() + render_shadow_casters() + end_shadow_pass() + + begin_scene_pass(env_now.horizon_color) + + // Sky first, depth-writes off: it is a background, and every later surface + // must be able to draw over it. + glDepthMask(false) + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glUseProgram(sky_program) + active_program = sky_program + vs_sky_bind_uniform(sky_program) + fs_sky_bind_uniform(sky_program) + fullscreen_pass() + glEnable(GL_DEPTH_TEST) + glDepthMask(true) + glEnable(GL_CULL_FACE) + glCullFace(GL_BACK) + glFrontFace(GL_CCW) + + render_river(game_time) + render_all(game_time) + + begin_transparent_span() + render_effects(game_time) + end_transparent_span() + + run_postfx(build_post_settings(game_time)) + + // HUD lands on the back buffer after tone mapping, so it keeps its designed + // colours instead of being graded with the scene. Culling stays off for the + // whole overlay -- glyph quads have no reliable winding. + glDisable(GL_CULL_FACE) + let saved_fog = begin_hud_overlay() + draw_hud_3d(game_time) + draw_hud() + end_hud_overlay(saved_fog) + glEnable(GL_CULL_FACE) +} + +[export] +def update() { + if (!live_begin_frame()) { + return + } + + live_get_framebuffer_size(display_w, display_h) + let aspect = (display_h != 0 ? float(display_w) / float(display_h) : 1.0) + v_projection = perspective_rh_minus1_to_1(CAM_FOV_DEG * PI / 180.0, aspect, Z_NEAR, Z_FAR) + + let real_dt = get_dt() + tick_dt = min(real_dt, 1.0 / 30.0) + + if (slow_mo_timer > 0.0) { + slow_mo_timer -= real_dt + tick_dt *= SLOW_MO_SCALE + } + + screen_shake_amount = max(screen_shake_amount - SHAKE_DECAY * real_dt, 0.0) + + if (restart_input_lock > 0.0) { + restart_input_lock -= tick_dt + } + + let game_time = get_uptime() + + handle_menu_input() + handle_pause_input() + simulate() + + update_music_state() + update_engine_sound(game_state == GameState.playing, player_fwd_speed, + PLAYER_FWD_SPEED_MAX * section_speed_mult()) + + update_camera(aspect) + render_frame(game_time) + + live_end_frame() +} + +[export] +def shutdown() { + shutdown_audio() + live_destroy_window() +} + +var max_frame_limit = 0 + +def parse_args() { + let args <- get_command_line_arguments() + for (i in range(length(args) - 1)) { + if (args[i] == "--max-frames") { + max_frame_limit = try_to_int(args[i + 1]) ?? 0 // garbage = no cap + } + } +} + +[export] +def main() { + parse_args() + init() + var frame_count = 0 + while (!exit_requested()) { + update() + maybe_collect_gc() + frame_count++ + if (max_frame_limit > 0 && frame_count >= max_frame_limit) { + break + } + } + shutdown() +} diff --git a/web/examples/ui/samples/examples/river_run/river.das b/web/examples/ui/samples/examples/river_run/river.das new file mode 100644 index 0000000000..4b18cb9f08 --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/river.das @@ -0,0 +1,641 @@ +options gen2 +options persistent_heap + +require rr_globals public + +// River bank geometry generation + rendering. +// River is defined by a ring of RiverSegment, each with left/right bank X at a world Y. +// Banks are rendered as a triangle strip rebuilt each frame. + +def river_section_amplitude() : float { + return 1.5 + float(min(current_section, 5)) * 0.5 +} + +def river_section_frequency() : float { + return 0.018 + float(min(current_section, 6)) * 0.003 +} + +def river_target_half_width() : float { + let s = float(min(current_section, 8)) + // Start wider/easier in section 0 and gradually narrow with progression. + let base = (RIVER_HALF_WIDTH_MAX + 1.15) - s * 0.33 + return clamp(base, RIVER_HALF_WIDTH_MIN + 0.75, RIVER_HALF_WIDTH_MAX + 1.8) +} + +def pick_split_half() : float { + let s = float(min(current_section, 9)) + // Section 0 keeps split center narrow (wider channels); later sections can narrow channels more. + let min_half = 0.68 + s * 0.09 + let max_half = min(3.15, 1.42 + s * 0.17) + let hi = max(max_half, min_half + 0.35) + if (random_f() < 0.35) { + return random_range((min_half + hi) * 0.55, hi) + } + return random_range(min_half, hi) +} + +def init_river() { + river_segments |> clear() + river_gen_y = player_pos.y - SEGMENT_LENGTH * 2.0 + river_center_x = 0.0 + river_half_width = RIVER_HALF_WIDTH_MAX + river_center_target = 0.0 + river_width_target = RIVER_HALF_WIDTH_MAX + // Guarantee a split in section 0, but not right at the start. + river_split_target = false + river_split_blend = 0.0 + river_split_half = 0.0 + river_split_hold = 0.0 + river_first_split_pending = true + river_first_split_start_y = player_pos.y + random_range(95.0, 150.0) + + for (_i in range(RIVER_SEGMENTS_COUNT)) { + advance_river_one() + } + river_dirty = true +} + +def advance_river_one() { + // Gradually drift center and width toward targets + river_center_x += (river_center_target - river_center_x) * 0.15 + river_half_width += (river_width_target - river_half_width) * 0.12 + + // Occasionally pick new targets + if (random_f() < 0.12) { + let max_drift = RIVER_CENTER_DRIFT_MAX - float(min(current_section, 5)) * 0.3 + river_center_target = random_range(-max_drift, max_drift) + } + if (random_f() < 0.12) { + let lo = max(RIVER_HALF_WIDTH_MIN + 0.6, river_target_half_width() - 2.2) + let hi = min(RIVER_HALF_WIDTH_MAX + 2.2, river_target_half_width() + 2.4) + river_width_target = random_range(lo, hi) + } + + // Force one split in section 0 once we get away from spawn. + if (river_first_split_pending && river_gen_y >= river_first_split_start_y) { + river_split_target = true + river_split_half = pick_split_half() + river_split_hold = random_range(90.0, 170.0) + river_first_split_pending = false + } + + // Split / merge events are allowed from section 0 onward. + let split_chance = 0.035 + float(min(current_section, 6)) * 0.008 + if (!river_first_split_pending && !river_split_target && river_split_blend <= 0.01 && random_f() < split_chance) { + river_split_target = true + river_split_half = pick_split_half() + river_split_hold = random_range(90.0, 190.0) + } + if (river_split_target && river_split_blend >= 0.99) { + river_split_hold -= SEGMENT_LENGTH + if (river_split_hold <= 0.0) { + river_split_target = false + } + } + if (river_split_target && random_f() < 0.10) { + river_split_half = pick_split_half() + } + + let split_speed = 0.10 + river_split_blend += (river_split_target ? split_speed : -split_speed) + river_split_blend = clamp(river_split_blend, 0.0, 1.0) + + if (river_split_blend <= 0.001) { + river_split_half = 0.0 + } + + // Add sine wave ripple on top + let amp = river_section_amplitude() + let freq = river_section_frequency() + let ripple = sin(river_gen_y * freq) * amp + + let center_line = river_center_x + ripple + let left_x = center_line - river_half_width + let right_x = center_line + river_half_width + + // Early sections guarantee roomier split channels; later sections may get tighter. + let min_channel_half = max(2.1, 3.25 - float(min(current_section, 8)) * 0.14) + let max_split_half = max(river_half_width - min_channel_half, 0.0) + let split_wave = 0.9 + 0.1 * sin(river_gen_y * 0.03) + let split_half = min(max_split_half, river_split_half * river_split_blend * split_wave) + let split_active = split_half > 0.35 + + let seg = RiverSegment( + left_bank_x = left_x, + right_bank_x = right_x, + split = split_active, + split_left_x = center_line - split_half, + split_right_x = center_line + split_half, + world_y = river_gen_y + ) + river_segments |> push(seg) + river_gen_y += SEGMENT_LENGTH +} + +def advance_river() { + // Drop segments that are behind the camera + let keep_y = player_pos.y - SEGMENT_LENGTH * 4.0 + var drop = 0 + for (seg in river_segments) { + if (seg.world_y < keep_y) { + drop++ + } else { + break + } + } + for (_i in range(drop)) { + river_segments |> erase(0) + advance_river_one() + } + if (drop > 0) { + river_dirty = true + } +} + +def sample_river_segment(world_y : float) : RiverSegment { + if (length(river_segments) < 2) { + return RiverSegment( + left_bank_x = -5.0, + right_bank_x = 5.0, + split = false, + split_left_x = 0.0, + split_right_x = 0.0, + world_y = world_y + ) + } + // Find surrounding segments + var best_lo = 0 + for (i in range(length(river_segments) - 1)) { + if (river_segments[i].world_y <= world_y) { + best_lo = i + } else { + break + } + } + let hi = min(best_lo + 1, length(river_segments) - 1) + let s0 = river_segments[best_lo] + let s1 = river_segments[hi] + let t = (s1.world_y > s0.world_y + ? clamp((world_y - s0.world_y) / (s1.world_y - s0.world_y), 0.0, 1.0) + : 0.0) + let split_blend = (s0.split ? 1.0 : 0.0) * (1.0 - t) + (s1.split ? 1.0 : 0.0) * t + return RiverSegment( + left_bank_x = s0.left_bank_x + (s1.left_bank_x - s0.left_bank_x) * t, + right_bank_x = s0.right_bank_x + (s1.right_bank_x - s0.right_bank_x) * t, + split = split_blend > 0.4, + split_left_x = s0.split_left_x + (s1.split_left_x - s0.split_left_x) * t, + split_right_x = s0.split_right_x + (s1.split_right_x - s0.split_right_x) * t, + world_y = world_y + ) +} + +// Returns clamped X range for the channel nearest prefer_x. +def river_clamp_x_for(world_y, prefer_x : float) : float2 { + let s = sample_river_segment(world_y) + if (!s.split) { + return float2(s.left_bank_x, s.right_bank_x) + } + let a = float2(s.left_bank_x, s.split_left_x) + let b = float2(s.split_right_x, s.right_bank_x) + if (prefer_x >= a.x && prefer_x <= a.y) { + return a + } + if (prefer_x >= b.x && prefer_x <= b.y) { + return b + } + let ac = (a.x + a.y) * 0.5 + let bc = (b.x + b.y) * 0.5 + return (abs(prefer_x - ac) < abs(prefer_x - bc) ? a : b) +} + +def river_clamp_x_secondary(world_y, prefer_x : float) : float2 { + let s = sample_river_segment(world_y) + if (!s.split) { + return float2(1.0, -1.0) + } + let a = float2(s.left_bank_x, s.split_left_x) + let b = float2(s.split_right_x, s.right_bank_x) + let p = river_clamp_x_for(world_y, prefer_x) + if (p.x == a.x && p.y == a.y) { + return b + } + return a +} + +// Compatibility helper used by existing gameplay code. +def river_clamp_x(world_y : float) : float2 { + return river_clamp_x_for(world_y, player_pos.x) +} + +// Check if world position is outside river banks +def is_out_of_river(pos : float3) : bool { + let p = river_clamp_x_for(pos.y, pos.x) + if (pos.x >= p.x && pos.x <= p.y) { + return false + } + let s = river_clamp_x_secondary(pos.y, pos.x) + return !(s.x <= s.y && pos.x >= s.x && pos.x <= s.y) +} + +// --- Terrain profile --- +// +// The banks are no longer a flat plane. Height is a function of distance from +// the waterline: a wet beach, a shoulder, then rolling hills that carry the eye +// out to the fog. Gameplay shares the same function (terrain_height) so props +// sit on the ground instead of hovering over it. + +// Terrain reaches this far outward from each bank, far enough that the fog has +// swallowed the mesh edge before it can read as a horizon line. +let TERRAIN_EXTEND = 150.0 +// Lateral spans per bank. Distance is distributed quadratically, so the +// shoreline -- where the silhouette matters -- gets most of the vertices. +let TERRAIN_SPANS = 12 +let ISLAND_SPANS = 4 + +def private thash(p : float2) : float { + let h = sin(dot(p, float2(127.1, 311.7))) * 43758.545 + return h - floor(h) +} + +def private tnoise(p : float2) : float { + let i = floor(p) + let f = p - i + let u = f * f * (float2(3.0) - 2.0 * f) + let a = thash(i) + let b = thash(i + float2(1.0, 0.0)) + let c = thash(i + float2(0.0, 1.0)) + let d = thash(i + float2(1.0, 1.0)) + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y) +} + +// Height above the waterline at `d` metres inland, sampled at world (wx, wy). +def bank_profile(d, wx, wy : float) : float { + if (d <= 0.0) { + return 0.0 + } + let p = float2(wx, wy) + // Wet sand lifting out of the water, then the bank shoulder. + var h = smoothstep(0.0, 2.6, d) * 0.42 + h += smoothstep(1.8, 11.0, d) * 1.75 + // Mid-scale dunes/mounds, then the far hills that build the skyline. + h += smoothstep(4.0, 26.0, d) * 1.9 * (tnoise(p * 0.055) - 0.35) + h += smoothstep(14.0, 95.0, d) * 11.0 * (tnoise(p * 0.017 + float2(19.0, 7.0)) - 0.30) + return max(h, 0.0) +} + +def private island_profile(d : float) : float { + if (d <= 0.0) { + return 0.0 + } + return smoothstep(0.0, 1.4, d) * 0.55 + smoothstep(0.6, 3.2, d) * 0.7 +} + +// Distance along y to the nearest END of the split run containing world_y. +// +// The banks only ever have to blend sideways -- they run the whole length of +// the river. The centre island does not: it starts and stops, and if its height +// only tapers laterally it meets open water at full crown height, which is the +// seam that runs down the river. Feeding min(lateral, longitudinal) into the +// same profile rounds the ends off too, so the island blends in every +// direction. +// +// A run that continues past the generated river returns a large distance rather +// than tapering, otherwise the island would grow a moving wall at the far end +// as new segments scroll in. +let ISLAND_OPEN_END = 1.0e6 + +def island_end_distance(world_y : float) : float { + let n = length(river_segments) + if (n < 2) { + return 0.0 + } + var here = -1 + for (i in range(n - 1)) { + if (river_segments[i].world_y <= world_y && world_y < river_segments[i + 1].world_y) { + here = i + break + } + } + if (here < 0) { + return ISLAND_OPEN_END + } + + var ahead = ISLAND_OPEN_END + for (step in range(n)) { + let i = here + 1 + step + if (i >= n) { + break + } + if (!river_segments[i].split) { + ahead = max(river_segments[i].world_y - world_y, 0.0) + break + } + } + + var behind = ISLAND_OPEN_END + for (step in range(n)) { + let i = here - step + if (i < 0) { + break + } + if (!river_segments[i].split) { + behind = max(world_y - river_segments[i].world_y, 0.0) + break + } + } + return min(ahead, behind) +} + +// Ground height at any world position: 0 on open water, the bank profile +// outside the channel, a low crown on the centre island of a split. +def terrain_height(wx, wy : float) : float { + let s = sample_river_segment(wy) + if (wx <= s.left_bank_x) { + return bank_profile(s.left_bank_x - wx, wx, wy) + } + if (wx >= s.right_bank_x) { + return bank_profile(wx - s.right_bank_x, wx, wy) + } + if (s.split && wx >= s.split_left_x && wx <= s.split_right_x) { + let d = min(wx - s.split_left_x, s.split_right_x - wx) + return island_profile(min(d, island_end_distance(wy))) + } + return 0.0 +} + +// --- Geometry Rebuild --- + +// Corners are given with their own normals. Each surface knows which height +// field it belongs to, so normals are computed at emit time rather than by a +// single after-the-fact pass -- that pass could only guess, and it guessed +// bank_profile for the centre-island vertices too. +def private push_strip_quad(var frag : GeometryFragment; p0, p1, p2, p3 : float3; + n0, n1, n2, n3 : float3; uv0, uv1, uv2, uv3 : float2) { + let base = length(frag.vertices) + frag.vertices |> push(GeometryPreviewVertex(xyz = p0, normal = n0, uv = uv0)) // nolint:STYLE012 - appends to the accumulating fragment + frag.vertices |> push(GeometryPreviewVertex(xyz = p1, normal = n1, uv = uv1)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p2, normal = n2, uv = uv2)) + frag.vertices |> push(GeometryPreviewVertex(xyz = p3, normal = n3, uv = uv3)) + frag.indices |> push(base + 0) // nolint:STYLE012 - appends to the accumulating fragment + frag.indices |> push(base + 1) + frag.indices |> push(base + 2) + frag.indices |> push(base + 2) + frag.indices |> push(base + 1) + frag.indices |> push(base + 3) +} + +// Central-difference normal of the analytic height field. Cheaper and smoother +// than averaging face normals, and it matches what the gameplay code samples. +def private profile_normal(d, wx, wy, side : float) : float3 { + let e = 0.6 + let hx0 = bank_profile(max(d - e, 0.0), wx - side * e, wy) + let hx1 = bank_profile(d + e, wx + side * e, wy) + let hy0 = bank_profile(d, wx, wy - e) + let hy1 = bank_profile(d, wx, wy + e) + let dx = (hx1 - hx0) / (2.0 * e) * side + let dy = (hy1 - hy0) / (2.0 * e) + return normalize(float3(-dx, -dy, 1.0)) +} + +// Quadratic span distribution: dense at the waterline, sparse out in the haze. +def private span_dist(i : int; count : int; extent : float) : float { + let t = float(i) / float(count) + return extent * t * t +} + +def private emit_bank(var frag : GeometryFragment; y0, y1, edge0, edge1, side : float) { + for (i in range(TERRAIN_SPANS)) { + let d0 = span_dist(i, TERRAIN_SPANS, TERRAIN_EXTEND) + let d1 = span_dist(i + 1, TERRAIN_SPANS, TERRAIN_EXTEND) + let x00 = edge0 + side * d0 + let x01 = edge0 + side * d1 + let x10 = edge1 + side * d0 + let x11 = edge1 + side * d1 + let p00 = float3(x00, y0, bank_profile(d0, x00, y0)) + let p01 = float3(x01, y0, bank_profile(d1, x01, y0)) + let p10 = float3(x10, y1, bank_profile(d0, x10, y1)) + let p11 = float3(x11, y1, bank_profile(d1, x11, y1)) + let n00 = profile_normal(d0, x00, y0, side) + let n01 = profile_normal(d1, x01, y0, side) + let n10 = profile_normal(d0, x10, y1, side) + let n11 = profile_normal(d1, x11, y1, side) + // Winding depends on which side we are on, so both banks face up. + if (side > 0.0) { + push_strip_quad(frag, p00, p01, p10, p11, n00, n01, n10, n11, + float2(d0, y0), float2(d1, y0), float2(d0, y1), float2(d1, y1)) + } else { + push_strip_quad(frag, p01, p00, p11, p10, n01, n00, n11, n10, + float2(d1, y0), float2(d0, y0), float2(d1, y1), float2(d0, y1)) + } + } +} + +// Central-difference normal of the island crown, taken through terrain_height so +// it follows the same field the geometry and the gameplay both use. +def private island_normal(wx, wy : float) : float3 { + let e = 0.35 + let hx1 = terrain_height(wx + e, wy) + let hx0 = terrain_height(wx - e, wy) + let hy1 = terrain_height(wx, wy + e) + let hy0 = terrain_height(wx, wy - e) + return normalize(float3(-(hx1 - hx0) / (2.0 * e), -(hy1 - hy0) / (2.0 * e), 1.0)) +} + +def private emit_island(var frag : GeometryFragment; y0, y1, l0, r0, l1, r1 : float) { + let half0 = (r0 - l0) * 0.5 + let half1 = (r1 - l1) * 0.5 + let c0 = (l0 + r0) * 0.5 + let c1 = (l1 + r1) * 0.5 + // Longitudinal distance to the run's ends, so the crown rounds off there. + let end0 = island_end_distance(y0) + let end1 = island_end_distance(y1) + for (i in range(ISLAND_SPANS)) { + let t0 = float(i) / float(ISLAND_SPANS) + let t1 = float(i + 1) / float(ISLAND_SPANS) + // Two mirrored halves so the crown runs down the middle of the island. + for (s in fixed_array(-1.0, 1.0)) { + let x00 = c0 + s * half0 * (1.0 - t0) + let x01 = c0 + s * half0 * (1.0 - t1) + let x10 = c1 + s * half1 * (1.0 - t0) + let x11 = c1 + s * half1 * (1.0 - t1) + // Each slice measures distance-from-edge against ITS OWN half width. + // Using the near slice's width for the far edge left neighbouring + // slices disagreeing about the height of the edge they share, which + // stepped the island along y while it stayed smooth across x. + let d00 = min(half0 * t0, end0) + let d01 = min(half0 * t1, end0) + let d10 = min(half1 * t0, end1) + let d11 = min(half1 * t1, end1) + let p00 = float3(x00, y0, island_profile(d00)) + let p01 = float3(x01, y0, island_profile(d01)) + let p10 = float3(x10, y1, island_profile(d10)) + let p11 = float3(x11, y1, island_profile(d11)) + let n00 = island_normal(x00, y0) + let n01 = island_normal(x01, y0) + let n10 = island_normal(x10, y1) + let n11 = island_normal(x11, y1) + if (s > 0.0) { + push_strip_quad(frag, p01, p00, p11, p10, n01, n00, n11, n10, + float2(d01, y0), float2(d00, y0), float2(d11, y1), float2(d10, y1)) + } else { + push_strip_quad(frag, p00, p01, p10, p11, n00, n01, n10, n11, + float2(d00, y0), float2(d01, y0), float2(d10, y1), float2(d11, y1)) + } + } + } +} + +def gen_river_banks() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + let n = length(river_segments) + if (n < 2) { + gen_bbox(frag) + return <- frag + } + let slices = n - 1 + let per_slice = (TERRAIN_SPANS * 2 + ISLAND_SPANS * 2) + frag.vertices |> reserve(slices * per_slice * 4) + frag.indices |> reserve(slices * per_slice * 6) + + for (i in range(slices)) { + let s0 = river_segments[i] + let s1 = river_segments[i + 1] + emit_bank(frag, s0.world_y, s1.world_y, s0.left_bank_x, s1.left_bank_x, -1.0) + emit_bank(frag, s0.world_y, s1.world_y, s0.right_bank_x, s1.right_bank_x, 1.0) + if (s0.split || s1.split) { + emit_island(frag, s0.world_y, s1.world_y, + s0.split_left_x, s0.split_right_x, s1.split_left_x, s1.split_right_x) + } + } + + gen_bbox(frag) + return <- frag +} + +// Water is subdivided laterally so uv.x can carry distance-to-shore; the shader +// turns that into the shallow-water tint and the foam band. +let WATER_SPANS = 10 + +// How far this point is from shore, for shading purposes. +// +// Measuring against the CHANNEL edges inside a split -- which is what the +// geometry is built from -- made the entire narrow channel read as shallow, and +// then flip to deep in a single slice the moment `split` went false. That is a +// hard line straight across the river. +// +// The centre island only shallows the water in proportion to how much island is +// actually there. Its crown tapers to nothing at the ends of a split run, so the +// water stops pretending there is a shore mid-channel exactly as the island +// stops being one, and the transition is smooth in both directions. +def private water_shore_dist(wx, wy : float; s : RiverSegment) : float { + let nearest_bank = min(wx - s.left_bank_x, s.right_bank_x - wx) + let d_bank = clamp(nearest_bank, 0.0, RIVER_HALF_WIDTH_MAX + TERRAIN_EXTEND) + if (!s.split) { + return d_bank + } + let half = (s.split_right_x - s.split_left_x) * 0.5 + let crown = island_profile(min(half, island_end_distance(wy))) + let presence = saturate(crown / 0.45) + let d_isl = min(abs(wx - s.split_left_x), abs(wx - s.split_right_x)) + return max(lerp(d_bank, min(d_bank, d_isl), presence), 0.0) +} + +def private emit_water_channel(var frag : GeometryFragment; s0, s1 : RiverSegment; + l0, r0, l1, r1, z : float) { + let y0 = s0.world_y + let y1 = s1.world_y + let w0 = r0 - l0 + let w1 = r1 - l1 + if (w0 <= 0.01 && w1 <= 0.01) { + return + } + for (i in range(WATER_SPANS)) { + let t0 = float(i) / float(WATER_SPANS) + let t1 = float(i + 1) / float(WATER_SPANS) + let x00 = l0 + w0 * t0 + let x01 = l0 + w0 * t1 + let x10 = l1 + w1 * t0 + let x11 = l1 + w1 * t1 + let d00 = water_shore_dist(x00, y0, s0) + let d01 = water_shore_dist(x01, y0, s0) + let d10 = water_shore_dist(x10, y1, s1) + let d11 = water_shore_dist(x11, y1, s1) + let up = float3(0.0, 0.0, 1.0) + push_strip_quad(frag, + float3(x00, y0, z), float3(x01, y0, z), + float3(x10, y1, z), float3(x11, y1, z), + up, up, up, up, + float2(d00, y0), float2(d01, y0), float2(d10, y1), float2(d11, y1)) + } +} + +def gen_river_surface() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + let n = length(river_segments) + if (n < 2) { + gen_bbox(frag) + return <- frag + } + let slices = n - 1 + frag.vertices |> reserve(slices * WATER_SPANS * 8) + frag.indices |> reserve(slices * WATER_SPANS * 12) + let z = -0.03 + + for (i in range(slices)) { + let s0 = river_segments[i] + let s1 = river_segments[i + 1] + if (s0.split || s1.split) { + emit_water_channel(frag, s0, s1, + s0.left_bank_x, s0.split_left_x, s1.left_bank_x, s1.split_left_x, z) + emit_water_channel(frag, s0, s1, + s0.split_right_x, s0.right_bank_x, s1.split_right_x, s1.right_bank_x, z) + } else { + emit_water_channel(frag, s0, s1, + s0.left_bank_x, s0.right_bank_x, s1.left_bank_x, s1.right_bank_x, z) + } + } + + gen_bbox(frag) + return <- frag +} + +def rebuild_river_geometry() { + finalize(river_bank_geo) + river_bank_geo <- create_geometry_fragment <| gen_river_banks() + finalize(river_surface_geo) + river_surface_geo <- create_geometry_fragment <| gen_river_surface() +} + +// --- Rendering --- + +def render_river(game_time : float) { + glUseProgram(water_program) + active_program = water_program + v_model = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + f_RiverColor = float4(get_river_color(), 1.0) + f_GameTime = game_time + vs_water_bind_uniform(active_program) + fs_water_bind_uniform(active_program) + river_surface_geo |> draw_geometry_fragment() + + glUseProgram(terrain_program) + active_program = terrain_program + v_model = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + f_BankColor = float4(get_bank_color(), 1.0) + f_ShoreColor = env_now.shore_color + f_RockColor = env_now.rock_color + f_Material = float4(0.95, 0.02, 0.0, 0.10) + vs_terrain_bind_uniform(active_program) + fs_terrain_bind_uniform(active_program) + river_bank_geo |> draw_geometry_fragment() +} + +// Banks cast into the shadow map as well, so a hill throws shade across the +// water at a low sun. +def render_river_shadow() { + v_model = compose(float3(0.0), float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + vs_shadow_bind_uniform(active_program) + river_bank_geo |> draw_geometry_fragment() +} diff --git a/web/examples/ui/samples/examples/river_run/rr_audio.das b/web/examples/ui/samples/examples/river_run/rr_audio.das new file mode 100644 index 0000000000..a06050c35e --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/rr_audio.das @@ -0,0 +1,566 @@ +options gen2 +options persistent_heap + +require rr_globals public + +// --- Audio State --- + +var @live snd_shoot : array +var @live snd_enemy_explode : array +var @live snd_player_hit : array +var @live snd_refuel : array +var @live snd_bridge_destroy : array +var @live snd_section_clear : array +var @live snd_low_fuel_beep : array +var @live snd_game_over : array +var @live snd_bonus_fuel : array +var @live snd_bonus_multishot : array +var @live snd_bonus_life : array +var @live snd_bonus_fastshot : array +var @live snd_engine_loop : array + +// These three describe LIVE PROCESS state, not game state, so they must NOT +// persist across a reload. shutdown_audio() runs before the reload tears the +// context down; if the flags came back true, init_audio() would skip re-init and +// the new context would drive a destroyed audio system and a strudel Stream +// owned by the freed old heap -- which aborts in Stream::push on a dead mutex. +// `asch` is deliberately non-@live for the same reason; the flags must match it. +var engine_sid : SID = INVALID_SID + +var audio_initialized = false +var asch : AudioSystemChannels + +var music_initialized = false +// The strudel player runs on its own worker. Under a single-threaded audio +// backend -- which is what the wasm build gets -- there is no worker to host it, +// so music is gated off there and the generated SFX carry the whole soundtrack. +// Set from audio_is_single_threaded() at init, so ONE source serves native and +// wasm alike. +var music_enabled = false +var g_music_tracks : table +var prev_music_state = GameState.menu +var low_fuel_music_active = false +var @live prev_section_for_music = -1 + +// --- PCM Generation --- + +def gen_sine_sweep(f1, f2, dur, vol : float) : array { + let n = int(float(AUDIO_RATE) * dur) + var inscope s : array + s |> resize(n) + for (i in range(n)) { + let t = float(i) / float(AUDIO_RATE) + let f = f1 + (f2 - f1) * (t / dur) + s[i] = sin(2.0 * PI * f * t) * vol + } + return <- s +} + +def gen_noise_burst(dur, vol : float) : array { + let n = int(float(AUDIO_RATE) * dur) + var inscope s : array + s |> resize(n) + var seed = 77u + for (i in range(n)) { + seed = seed * 1103515245u + 12345u + let noise = float(int(seed >> 16u) % 1000) / 500.0 - 1.0 + s[i] = noise * vol + } + return <- s +} + +def mix_into(var dst : array; src : array) { + let n = min(length(dst), length(src)) + for (i in range(n)) { + dst[i] += src[i] + } +} + +def build_note_sequence(notes : float[]; note_dur, volume : float; rising : bool) : array { + let total_dur = note_dur * float(length(notes)) + let total_samples = int(float(AUDIO_RATE) * total_dur) + var inscope seq : array + seq |> resize(total_samples) + for (ni in range(length(notes))) { + let f1 = notes[ni] + let f2 = (rising ? notes[ni] * 1.06 : notes[ni] * 0.92) + let note <- gen_sine_sweep(f1, f2, note_dur, volume) + let offset = ni * int(float(AUDIO_RATE) * note_dur) + for (i in range(length(note))) { + if (offset + i < total_samples) { + seq[offset + i] += note[i] + } + } + } + return <- seq +} + +def init_audio_samples() { + // release whatever a previous run left in the @live buffers before regenerating + delete snd_shoot + delete snd_enemy_explode + delete snd_player_hit + delete snd_refuel + delete snd_bridge_destroy + delete snd_section_clear + delete snd_low_fuel_beep + delete snd_game_over + delete snd_bonus_fuel + delete snd_bonus_multishot + delete snd_bonus_life + delete snd_bonus_fastshot + delete snd_engine_loop + + // shoot: short upward chirp + snd_shoot <- gen_sine_sweep(1200.0, 2200.0, 0.055, 0.18) + + // enemy explode: noise + low thump + snd_enemy_explode <- gen_noise_burst(0.12, 0.15) + let thump <- gen_sine_sweep(280.0, 60.0, 0.14, 0.22) + mix_into(snd_enemy_explode, thump) + + // player hit: harsh descending noise + snd_player_hit <- gen_noise_burst(0.22, 0.25) + let descend <- gen_sine_sweep(600.0, 80.0, 0.28, 0.2) + mix_into(snd_player_hit, descend) + + // refuel: ascending chord + let refuel_notes = fixed_array(330.0, 440.0, 550.0, 660.0) + snd_refuel <- build_note_sequence(refuel_notes, 0.07, 0.15, true) + + // bridge destroy: deep rumble burst + snd_bridge_destroy <- gen_noise_burst(0.28, 0.2) + let rumble <- gen_sine_sweep(180.0, 40.0, 0.3, 0.28) + mix_into(snd_bridge_destroy, rumble) + + // section clear: triumphant arpeggio + let section_notes = fixed_array(330.0, 415.0, 523.0, 659.0, 830.0) + snd_section_clear <- build_note_sequence(section_notes, 0.09, 0.18, true) + + // low fuel beep: short warning tone + snd_low_fuel_beep <- gen_sine_sweep(880.0, 840.0, 0.07, 0.14) + + // game over: descending sweep + let go_notes = fixed_array(523.0, 415.0, 330.0, 247.0, 196.0) + snd_game_over <- build_note_sequence(go_notes, 0.13, 0.22, false) + + // bonus fuel: bright 2-note up chirp + let bf_notes = fixed_array(440.0, 660.0) + snd_bonus_fuel <- build_note_sequence(bf_notes, 0.06, 0.16, true) + + // bonus multishot: sharper metallic rise + snd_bonus_multishot <- gen_sine_sweep(700.0, 1600.0, 0.1, 0.2) + + // bonus life: celebratory triad climb + let bl_notes = fixed_array(392.0, 523.0, 659.0) + snd_bonus_life <- build_note_sequence(bl_notes, 0.08, 0.2, true) + + // bonus fast shot: rapid double-blip + snd_bonus_fastshot <- gen_sine_sweep(900.0, 1800.0, 0.06, 0.18) + + // Engine rumble loop: 0.5 s periodic harmonics so the loop seam is click-free. + let eng_samples = int(float(AUDIO_RATE) * 0.5) + var inscope eng : array + eng |> resize(eng_samples) + for (i in range(eng_samples)) { + let t = float(i) / float(AUDIO_RATE) + eng[i] = ( + sin(2.0 * PI * 80.0 * t) * 0.55 + + sin(2.0 * PI * 160.0 * t) * 0.28 + + sin(2.0 * PI * 240.0 * t) * 0.12 + ) * 0.13 + } + eng[eng_samples - 1] = eng[0] + snd_engine_loop <- eng +} + +def play_sfx(samples : array) { + if (!audio_initialized) { + return + } + var copy <- clone(samples) + play_sound_from_pcm(AUDIO_RATE, AUDIO_CHANNELS, copy) +} + +def update_engine_sound(active : bool; speed, max_speed : float) { + if (!audio_initialized || engine_sid == INVALID_SID) { + return + } + set_pause(engine_sid, !active) + if (active) { + // Speed range PLAYER_FWD_SPEED_MIN..max_speed maps to pitch 0.75..1.55. + let hi = max(max_speed, PLAYER_FWD_SPEED_MIN + 0.01) + let t = clamp((speed - PLAYER_FWD_SPEED_MIN) / (hi - PLAYER_FWD_SPEED_MIN), 0.0, 1.0) + set_pitch(engine_sid, 0.75 + t * 0.80) + set_volume(engine_sid, 0.30, 0.05) + } else { + set_volume(engine_sid, 0.0, 0.1) + } +} + +// --- Music --- + +def replace_named_track(name : string; var pat : Pattern; gain : float) { + if (key_exists(g_music_tracks, name)) { + strudel_remove_track(g_music_tracks[name]) + } + g_music_tracks[name] = strudel_add_track(pat, gain) +} + +def music_cmd(cmd : string) { + let parts <- split(cmd, ":") + if (length(parts) < 2) { + return + } + let action = parts[0] + let name = parts[1] + if (action == "play" && length(parts) >= 3) { + let gain = (length(parts) >= 4 ? float(to_double(parts[3])) : 1.0) + if (key_exists(g_music_tracks, name)) { + strudel_remove_track(g_music_tracks[name]) + } + g_music_tracks[name] = strudel_add_track(s(parts[2]), gain) + } elif (action == "note" && length(parts) >= 4) { + let gain = (length(parts) >= 5 ? float(to_double(parts[4])) : 0.3) + if (key_exists(g_music_tracks, name)) { + strudel_remove_track(g_music_tracks[name]) + } + g_music_tracks[name] = strudel_add_track(note_pattern(parts[2], parts[3]), gain) + } elif (action == "stop") { + if (key_exists(g_music_tracks, name)) { + let fade_time = (length(parts) >= 3 ? float(to_double(parts[2])) : 0.5) + strudel_fade_track(g_music_tracks[name], 0.0, fade_time) + } + } elif (action == "rich") { + build_rich_track(name) + } +} + +// Rich tracks are built on the strudel thread so they can use the full +// Pattern API (chunk/sometimesby/jux/etc.). +def build_rich_track(name : string) { + if (name == "0") { + build_section0_rich() + } elif (name == "1") { + build_section1_rich() + } elif (name == "2") { + build_section2_rich() + } elif (name == "3") { + build_section3_rich() + } elif (name == "4") { + build_section4_rich() + } elif (name == "menu") { + build_menu_rich() + } elif (name == "tension") { + build_tension_rich() + } elif (name == "gameover") { + build_game_over_rich() + } elif (name == "win") { + build_win_rich() + } +} + +def stop_all_tracks(fade : float) { + strudel_command("stop:drums:{fade}") + strudel_command("stop:bass:{fade}") + strudel_command("stop:lead:{fade}") + strudel_command("stop:arp:{fade}") + strudel_command("stop:pad:{fade}") +} + +def start_menu_music() { + strudel_set_bpm(96.0lf) + strudel_command("rich:menu") +} + +def start_tension_music() { + strudel_set_bpm(110.0lf) + strudel_command("rich:tension") +} + +def start_game_over_music() { + strudel_set_bpm(80.0lf) + strudel_command("rich:gameover") +} + +def start_win_music() { + strudel_set_bpm(148.0lf) + strudel_command("rich:win") +} + +// ─── Per-voice rich builders ───────────────────────────────────────── +// +// The first pass leaned on a raw sawtooth lead through jux(rev) + phaser, which +// is the classic arcade-chiptune signature: bright, swirly and harsh. Cleaner +// here means filtering the oscillator rather than replacing it -- a low-passed +// saw keeps the bite without the fizz -- plus a room to sit the voices in a +// space instead of hard against the ear, and chorus for width in place of the +// hard stereo flip. +// +// Variety comes from two places. The pattern strings below are eight cycles +// long with mixed subdivisions and rests, so a melody takes eight bars to come +// round rather than four. On top of that each voice carries structural +// variation -- off() for a delayed answering voice, iter() to rotate the arp, +// sometimesby() for ornaments -- so no two passes through the loop land +// identically. + +def rich_drums(pattern : string; g : float) { + replace_named_track("drums", + s(pattern) + |> gain(saw() |> range(0.74, 1.0) |> fast(2.0lf)) + |> room(0.16), + g) +} + +def rich_bass(pattern, osc : string; g : float) { + replace_named_track("bass", + note_pattern(pattern, osc) + |> superimpose(@(var x : Pattern) => x |> add(-12.0) |> gain(0.55)) + // A fixed low pass is what turns a square bass from buzzy into round. + // Modulating it costs an extra signal pattern per event for no audible + // gain down here. + |> lpf(680.0) + |> attack(0.006) + |> release(0.20) + |> room(0.10), + g) +} + +def rich_lead(pattern, osc : string; g : float) { + replace_named_track("lead", + note_pattern(pattern, osc) + // Cleanliness is the filter, not the oscillator: a low-passed saw keeps + // the bite without the fizz that made the first pass read as arcade. + // Depth here is deliberately shallow -- the strudel evaluator recurses + // per event, and a deeper chain (off + two sometimesby + modulated + // filters, across five tracks) overflows its stack. + |> sometimesby(0.14, @(var x : Pattern) => x |> add(12.0)) + |> lpf(2600.0) + |> chorus(0.30) + |> attack(0.010) + |> release(0.26) + |> room(0.26) + |> gain(perlin() |> range(0.80, 1.0) |> slow(3.0lf)), + g) +} + +def rich_arp(pattern : string; g : float) { + replace_named_track("arp", + note_pattern(pattern, "triangle") + // iter rotates the figure by one step each cycle, so the arp walks + // through its own inversions instead of repeating. It is the cheapest + // variety in the mix: pure re-indexing, no extra events. + |> iter(4) + |> lpf(3200.0) + |> attack(0.006) + |> release(0.22) + |> room(0.34), + g) +} + +// A sustained chord bed under everything, mixed low on purpose -- it carries no +// melody and exists to give the other voices a floor to stand on, which is most +// of what separated the first pass from sounding like a soundtrack. +def rich_pad(pattern : string; g : float) { + replace_named_track("pad", + note_pattern(pattern, "triangle") + |> lpf(900.0) + |> attack(0.35) + |> release(0.9) + |> sustain(0.8) + |> room(0.55), + g) +} + +def fade_arp(fade : float) { + if (key_exists(g_music_tracks, "arp")) { + strudel_fade_track(g_music_tracks["arp"], 0.0, fade) + } +} + +def fade_pad(fade : float) { + if (key_exists(g_music_tracks, "pad")) { + strudel_fade_track(g_music_tracks["pad"], 0.0, fade) + } +} + +// ─── Section builders (gameplay loop, square+saw+triangle stack) ───── + +def build_section0_rich() { + rich_drums(" ", 0.42) + rich_bass(" ", "square", 0.28) + rich_lead("<[e4 ~] g4 [b4 a4] ~> <[g4 b4] e5 ~ d5> <[e4 fs4] g4 [b4 d5] ~> <[a4 b4] ~ e4 ~> <[e4 g4] [b4 g4] e4 ~>", "sawtooth", 0.19) + rich_arp("<[e5 b4 g4 e4]> <[d5 a4 f4 d4]> <[c5 g4 e4 c4]> <[b4 fs4 d4 b3]>", 0.09) + rich_pad(" ", 0.13) +} + +def build_section1_rich() { + rich_drums(" ", 0.46) + rich_bass(" ", "square", 0.30) + rich_lead(" <[g4 b4] d5 ~ [b4 g4]> <[e4 g4] b4 [e5 ~] d5> <[a4 ~] c5 [e5 d5] ~> <[f4 a4] ~ c5 [a4 f4]> ", "sawtooth", 0.21) + rich_arp("<[a5 e5 c5 a4]> <[g5 d5 b4 g4]> <[f5 c5 a4 f4]> <[e5 b4 g4 e4]>", 0.09) + rich_pad(" ", 0.14) +} + +def build_section2_rich() { + rich_drums("<[bd bd] [hh hh] [sd sd] [hh hh]> <[bd bd] [hh cp] [sd sd] [cp hh]> <[bd bd] hh sd [hh hh]>", 0.50) + rich_bass(" ", "square", 0.32) + rich_lead("<[d4 f4] a4 ~ [d5 a4]> <[bf3 d4] f4 [bf4 ~] a4> <[a3 c4] e4 ~ a4> <[c4 e4] g4 [c5 ~] e5> <[a3 e4] [a4 c5] ~ ~>", "sawtooth", 0.23) + rich_arp("<[d5 a4 f4 d4]> <[c5 g4 e4 c4]> <[bf4 f4 d4 bf3]> <[a4 e4 c4 a3]>", 0.10) + rich_pad(" ", 0.15) +} + +def build_section3_rich() { + rich_drums("<[bd bd] [hh hh] [sd cp] [hh hh]> <[bd bd] [hh hh] [sd sd] [cp cp]> <[bd bd] [hh hh] [sd cp] [hh hh]>", 0.54) + rich_bass(" ", "square", 0.34) + rich_lead("<[b4 d5] fs5 ~ b5> <[a4 c5] e5 ~ [c5 a4]> <[fs4 a4] c5 [fs5 ~] e5> <[b4 ~] d5 [fs5 e5] ~> <[g4 b4] ~ d5 [b4 g4]> ", "sawtooth", 0.25) + rich_arp("<[b5 fs5 d5 b4]> <[a5 e5 c5 a4]> <[g5 d5 b4 g4]> <[fs5 c5 a4 fs4]>", 0.12) + rich_pad(" ", 0.16) +} + +def build_section4_rich() { + rich_drums("<[bd bd] [hh hh] [sd cp] [hh cp]> <[bd cp] [hh hh] [sd sd] [hh cp]> <[bd bd] [hh cp] [sd cp] [cp hh]> <[bd bd] [hh hh] [sd cp] [hh hh]>", 0.58) + rich_bass(" ", "square", 0.36) + rich_lead("<[g4 bf4] d5 ~ [g5 d5]> <[ef4 g4] bf4 [ef5 ~] d5> <[d4 f4] a4 ~ d5> <[f4 a4] c5 [f5 ~] a5> <[d4 a4] [d5 f5] ~ ~>", "sawtooth", 0.27) + rich_arp("<[g5 d5 bf4 g4]> <[f5 c5 a4 f4]> <[ef5 bf4 g4 ef4]> <[d5 a4 f4 d4]>", 0.13) + rich_pad(" ", 0.17) +} + +// ─── Transition builders (menu / tension / game-over / win) ────────── +// Same effect chain, but oscillator/density per their original mood: +// menu/tension/game-over use sine bass + triangle lead (sparser), +// win uses square+saw+triangle like the gameplay sections. + +def build_menu_rich() { + rich_drums(" <~ ~ hh ~>", 0.18) + rich_bass(" ", "sine", 0.20) + rich_lead(" ", "triangle", 0.10) + rich_pad(" ", 0.16) + fade_arp(0.4) +} + +def build_tension_rich() { + rich_drums(" ", 0.22) + rich_bass(" ", "sine", 0.26) + rich_lead(" ", "triangle", 0.14) + rich_pad(" ", 0.18) + fade_arp(0.4) +} + +def build_game_over_rich() { + rich_drums(" ", 0.18) + rich_bass(" ", "sine", 0.26) + rich_lead(" ", "triangle", 0.16) + rich_pad(" ", 0.20) + fade_arp(0.3) +} + +def build_win_rich() { + rich_drums(" ", 0.54) + rich_bass("<[e2 e2] [g2 g2] [b2 b2] [e3 e3]> <[d3 d3] [b2 b2] [a2 a2] [g2 g2]> <[c3 c3] [a2 a2] [g2 g2] [b2 b2]> <[e2 e2] [g2 g2] [b2 b2] [e3 e3]>", "square", 0.28) + rich_lead(" ", "sawtooth", 0.22) + rich_arp("<[e6 b5 g5 e5]> <[g6 d6 b5 g5]> <[a6 e6 c6 a5]> <[b6 fs6 d6 b5]>", 0.12) + rich_pad(" ", 0.16) +} + +// 5 distinct gameplay music variants (cycles through sections 0..4). +// BPM is set on the main thread (matching menu/tension/win functions); +// the rich track build runs on the strudel thread via music_cmd. +def start_section_music(section : int) { + let s = section % 5 + if (s == 0) { + strudel_set_bpm(128.0lf) + strudel_command("rich:0") + } elif (s == 1) { + strudel_set_bpm(136.0lf) + strudel_command("rich:1") + } elif (s == 2) { + strudel_set_bpm(140.0lf) + strudel_command("rich:2") + } elif (s == 3) { + strudel_set_bpm(148.0lf) + strudel_command("rich:3") + } else { + strudel_set_bpm(158.0lf) + strudel_command("rich:4") + } +} + +def strudel_music_main() { + start_menu_music() + strudel_play(@@music_cmd) +} + +def update_music_state() { + if (!music_enabled || !music_initialized) { + return + } + let low_fuel = (game_state == GameState.playing && player_fuel < LOW_FUEL_THRESHOLD) + if (game_state != prev_music_state) { + if (game_state == GameState.menu) { + start_menu_music() + } elif (game_state == GameState.playing) { + if (low_fuel) { + start_tension_music() + low_fuel_music_active = true + } else { + start_section_music(current_section) + low_fuel_music_active = false + } + prev_section_for_music = current_section + } elif (game_state == GameState.game_over_state) { + start_game_over_music() + } elif (game_state == GameState.win_state) { + start_win_music() + } + prev_music_state = game_state + } elif (game_state == GameState.playing) { + let section_changed = (current_section != prev_section_for_music) + if (low_fuel && !low_fuel_music_active) { + start_tension_music() + low_fuel_music_active = true + prev_section_for_music = current_section + } elif (!low_fuel && (low_fuel_music_active || section_changed)) { + start_section_music(current_section) + low_fuel_music_active = false + prev_section_for_music = current_section + } + } +} + +def init_audio() { + if (!audio_initialized) { + asch = audio_system_create() + audio_initialized = true + init_audio_samples() + var loop_copy <- clone(snd_engine_loop) + engine_sid = play_sound_loop_from_pcm(AUDIO_RATE, AUDIO_CHANNELS, loop_copy) + set_volume(engine_sid, 0.0) + set_pause(engine_sid, true) + } + if (!audio_initialized) { + return + } + // The playground interpreter shares the renderer's runtime and cannot host + // the threaded strudel worker safely, and it does not report a + // single-threaded audio backend, so the threading check alone would not + // catch it. Only a standalone build may turn music on. SFX stay on here. + music_enabled = is_standalone_exe() && !audio_is_single_threaded() + if (music_enabled && !music_initialized) { + strudel_init(@@strudel_music_main) + strudel_set_volume(0.35, 0.0) + music_initialized = true + prev_music_state = game_state + } +} + +def shutdown_audio() { + if (music_initialized) { + strudel_shutdown() + music_initialized = false + } + if (audio_initialized) { + audio_system_finalize(asch.command, asch.next_sid) + audio_initialized = false + } +} diff --git a/web/examples/ui/samples/examples/river_run/rr_globals.das b/web/examples/ui/samples/examples/river_run/rr_globals.das new file mode 100644 index 0000000000..8332c01609 --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/rr_globals.das @@ -0,0 +1,631 @@ +options gen2 +options persistent_heap + +// river run -- web playground port (live/* + live_host replaced by live_stub) + +// Playground shim: replaces the whole live/* + live_host stack (window, clock, +// no-op [live_command]). Static requires cannot be switched at runtime, which +// is the reason this port exists as a copy at all. +require live_stub public +require glfw/glfw_boost public +require opengl/opengl_boost public +require opengl/opengl_gen public +require opengl/opengl_cache public +require rr_shaders public +require rr_models public +require rr_postfx public +require audio/audio_boost public +require strudel/strudel public +require strudel/strudel_player public +require daslib/math_boost public +require daslib/json public +require daslib/json_boost public +require daslib/strings_boost public +require daslib/safe_addr public +require opengl/opengl_ttf public +require daslib/decs_boost public + +// --- Constants --- + +let RIVER_HALF_WIDTH_MAX = 7.0 +let RIVER_HALF_WIDTH_MIN = 3.0 +let RIVER_CENTER_DRIFT_MAX = 3.5 +let SEGMENT_LENGTH = 6.0 +let RIVER_SEGMENTS_COUNT = 52 +let SECTION_LENGTH = 240.0 +let MAX_SECTIONS = 10 + +let PLAYER_Z = 2.5 +let BOAT_Z = 0.3 +let PLANE_Z = 2.5 +let ENEMY_HELI_Z = 2.5 +let BRIDGE_Z = 0.0 +let ISLAND_Z = 0.0 +let FUEL_DEPOT_Z = 0.2 + +let PLAYER_STRAFE_SPEED = 8.0 +let PLAYER_FWD_SPEED_MIN = 4.0 +let PLAYER_FWD_SPEED_MAX = 14.0 +let PLAYER_FRICTION = 0.88 +let PLAYER_FUEL_MAX = 100.0 +let PLAYER_FUEL_DRAIN = 1.8 +let PLAYER_INVULN_TIME = 2.5 +let PLAYER_RESPAWN_HIDE = 1.5 +let PLAYER_RESPAWN_BLINK = 1.0 +let PLAYER_SIZE = 0.55 +let PLAYER_ROTOR_SIZE = 1.0 + +let BULLET_SPEED = 38.0 +let BULLET_LIFETIME = 1.2 +let BULLET_SIZE = 0.12 +let FIRE_COOLDOWN = 0.312 + +let BONUS_DROP_CHANCE = 0.33 +let BONUS_PICKUP_RADIUS = 0.9 +let BONUS_FUEL_AMOUNT = 30.0 +let BONUS_MULTISHOT_TIME = 12.0 +let BONUS_FASTSHOT_TIME = 7.0 +let BONUS_BOB_AMPLITUDE = 0.22 +let BONUS_BOB_SPEED = 2.6 +let BONUS_SPIN_SPEED = 2.4 + +let ENEMY_BULLET_SPEED = 10.0 +let ENEMY_HELI_BULLET_SPEED = 18.0 +let ENEMY_BULLET_LIFETIME = 2.2 +let ENEMY_BULLET_SIZE = 0.14 +let ENEMY_HELI_RANGE = 28.0 +let ENEMY_HELI_SHOOT_INTERVAL_FAST = 1.2 + +let BOAT_SIZE = 0.7 +let PLANE_SIZE = 0.6 +let ENEMY_HELI_SIZE = 0.55 + +let BOAT_PATROL_SPEED = 2.5 +let BOAT_SHOOT_INTERVAL = 2.8 +let PLANE_SPEED = 6.0 +let PLANE_SHOOT_INTERVAL = 1.8 +let ENEMY_HELI_SPEED = 3.5 +let ENEMY_HELI_SHOOT_INTERVAL = 2.2 + +let FUEL_DEPOT_SIZE = 1.2 +let REFUEL_RATE = 35.0 +let REFUEL_RADIUS = 1.5 +let LOW_FUEL_THRESHOLD = 25.0 + +let BRIDGE_SPAN_Z = 3.2 +let BRIDGE_GAP_WIDTH = 2.5 +let BRIDGE_HEALTH_MIN = 2 +let BRIDGE_HEALTH_MAX = 4 +let BRIDGE_HEALTH_STEP_SECTIONS = 3 +let ISLAND_SIZE_MIN = 1.0 +let ISLAND_SIZE_MAX = 2.5 + +let PARTICLE_LIFETIME = 0.7 +let TRAIL_LIFETIME = 0.12 + +let CAM_BACK = 14.0 +let CAM_LOOK_AHEAD = 11.0 +let CAM_X_LAG = 0.08 +// Pitch is the camera's angle below horizontal. It has to stay under half the +// vertical FOV or the horizon never enters the frame -- which is why the old +// 42-degree framing showed nothing but ground. +let CAM_PITCH_SLOW_DEG = 19.0 // pitch at PLAYER_FWD_SPEED_MIN +let CAM_PITCH_FAST_DEG = 15.0 // pitch at top speed (PLAYER_FWD_SPEED_MAX * section_speed_mult) + +let SHAKE_SMALL = 0.20 +let SHAKE_MED = 0.55 +let SHAKE_BIG = 1.05 +let SHAKE_DECAY = 5.0 + +let SLOW_MO_DURATION = 0.6 +let SLOW_MO_SCALE = 0.4 + +let WAVE_BANNER_DURATION = 2.5 + +let AUDIO_RATE = 48000 +let AUDIO_CHANNELS = 1 + +// --- Enums --- + +enum GameState { menu, playing, paused, game_over_state, win_state } + +enum BonusType { + fuel + multishot + life + fastshot +} + +enum EnemyKind { + boat + plane + helicopter + bridge + depot +} + +// --- DECS Templates --- + +[decs_template] +struct EnemyBoat { + pos : float3 + vel : float3 + health : int + shoot_timer : float + patrol_dir : float + lane_prefer_x : float +} + +[decs_template] +struct EnemyPlane { + pos : float3 + vel : float3 + health : int + shoot_timer : float +} + +[decs_template] +struct EnemyHelicopter { + pos : float3 + vel : float3 + health : int + shoot_timer : float + hover_phase : float + target_x : float +} + +[decs_template] +struct PlayerBullet { + pos : float3 + vel : float3 + age : float +} + +[decs_template] +struct EnemyBullet { + pos : float3 + vel : float3 + age : float + bullet_type : int // 0=boat(sphere), 1=heli(red cyl), 2=plane(orange cyl) +} + +[decs_template] +struct FuelDepot { + pos : float3 +} + +[decs_template] +struct Bridge { + pos : float3 + gap_center_x : float + left_x : float + right_x : float + health : int +} + +[decs_template] +struct Island { + pos : float3 + size : float +} + +// One template covers everything growing on the bank -- conifers, broadleaf, +// dead trees, bushes, boulders and reeds -- because they differ only in which +// mesh they draw and how they are tinted. `kind` selects the mesh; `seed` +// selects which pre-generated instance of that mesh, so no two neighbours are +// the same object. +[decs_template] +struct RiverTree { + pos : float3 + size : float + tiers : int + green_tint : float + green_shift : float + trunk_ratio : float + kind : int + seed : int + yaw : float +} + +[decs_template] +struct RiverHouse { + pos : float3 + size : float + body_tint : float + roof_tint : float +} + +[decs_template] +struct BonusPickup { + pos : float3 + bonus_type : BonusType + bob_phase : float +} + +// Explosions are layered, and each layer moves and draws differently, so the +// particle carries its kind rather than having the renderer infer one from a +// lifetime threshold. +enum ParticleKind { + trail // bullet tracer dots + debris // spinning solid chunks, gravity + ember // small bright sparks, gravity, additive + smoke // dark puff, rises and expands, outlives everything else + fire // expanding fireball, cools white -> orange -> red + flash // one very short white blowout at the instant of the hit + shock // flat ring racing outward across the water +} + +[decs_template] +struct Particle { + pos : float3 + vel : float3 + color : float3 + lifetime : float + max_life : float + size : float + spin_speed : float + spin_phase : float + kind : ParticleKind + drag : float + gravity : float +} + +// --- River Segment --- + +struct RiverSegment { + left_bank_x : float + right_bank_x : float + split : bool + split_left_x : float + split_right_x : float + world_y : float +} + +// --- Hit collection --- + +struct EnemyHitInfo { + bullet_eid : EntityId + enemy_eid : EntityId + pos : float3 + enemy_kind : EnemyKind + score_value : int + blast_radius : float +} + +// --- Global Game State --- + +var @live game_state = GameState.menu +var @live score = 0 +var @live player_lives = 3 +var @live player_pos = float3(0.0, 0.0, PLAYER_Z) +var @live player_vel = float3(0.0, 0.0, 0.0) +var @live player_fwd_speed = PLAYER_FWD_SPEED_MIN +var @live player_fuel = PLAYER_FUEL_MAX +var @live player_invuln_timer = 0.0 +var @live player_respawn_timer = 0.0 +var @live player_shoot_cooldown = 0.0 +var @live player_multishot_timer = 0.0 +var @live player_fastshot_timer = 0.0 +var @live cam_x = 0.0 + +var @live current_section = 0 +var @live section_dist = 0.0 +var @live section_banner_timer = 0.0 +var @live section_score_bonus_pending = false +var @live restart_input_lock = 0.0 +var @live god_mode = false +var @live screen_shake_amount = 0.0 +var @live slow_mo_timer = 0.0 + +var @live river_segments : array +var @live river_dirty = true +var @live river_gen_y = 0.0 +var @live river_center_x = 0.0 +var @live river_half_width = 6.0 +var @live river_center_target = 0.0 +var @live river_width_target = 6.0 +var @live river_split_target = false +var @live river_split_blend = 0.0 +var @live river_split_half = 0.0 +var @live river_split_hold = 0.0 +var @live river_first_split_pending = true +var @live river_first_split_start_y = 0.0 + +var tick_dt = 0.0 +// Effect-inspection rig: with fx_freeze set, the world and its particles both +// stop, so a blast can be held still and stepped by hand (cmd_fx_freeze / +// cmd_fx_step). A screenshot round-trip is far longer than a 100ms flash, so +// there is no other way to actually look at one. +var fx_freeze = false +var global_rng_seed = 12345u +var display_w = 0 +var display_h = 0 + +var @live low_fuel_beep_timer = 0.0 +var @live refuel_sound_timer = 0.0 + +// --- Section environments --- +// +// Each section is a whole lighting setup, not just a river tint: sun colour and +// elevation, sky and horizon, fog, exposure and grade. The look of the game +// therefore changes with progress without a single authored texture, and the +// post chain reads the same values, so bloom and shafts stay in key with the +// biome. + +struct SectionEnv { + sun_dir : float3 + sun_color : float3 + sky_color : float3 + horizon_color : float3 + bounce_color : float3 + fog_color : float3 + fog_density : float + river_color : float3 + bank_color : float3 + shore_color : float3 + rock_color : float3 + foliage_tint : float3 + night : float + exposure : float + bloom : float + shafts : float + grade_tint : float3 + grade_amount : float + name : string +} + +def private env_dawn() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(0.26, 0.95, 0.125)), + sun_color = float3(1.45, 1.16, 0.82), + sky_color = float3(0.22, 0.40, 0.78), + horizon_color = float3(0.92, 0.68, 0.46), + bounce_color = float3(0.10, 0.17, 0.26), + fog_color = float3(0.78, 0.72, 0.70), + fog_density = 0.0062, + river_color = float3(0.06, 0.24, 0.52), + bank_color = float3(0.26, 0.44, 0.20), + shore_color = float3(0.62, 0.55, 0.42), + rock_color = float3(0.34, 0.33, 0.32), + foliage_tint = float3(1.0, 0.97, 0.86), + night = 0.0, + exposure = 1.05, + bloom = 0.40, + shafts = 0.80, + grade_tint = float3(1.06, 0.98, 0.92), + grade_amount = 0.10, + name = "DELTA DAWN" + ) +} + +def private env_desert() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(0.20, 0.93, 0.30)), + sun_color = float3(1.72, 1.52, 1.18), + sky_color = float3(0.30, 0.50, 0.85), + horizon_color = float3(0.95, 0.83, 0.60), + bounce_color = float3(0.30, 0.24, 0.15), + fog_color = float3(0.88, 0.80, 0.62), + fog_density = 0.0080, + river_color = float3(0.10, 0.30, 0.44), + bank_color = float3(0.62, 0.50, 0.26), + shore_color = float3(0.80, 0.70, 0.46), + rock_color = float3(0.52, 0.42, 0.30), + foliage_tint = float3(1.0, 0.94, 0.72), + night = 0.0, + exposure = 0.92, + bloom = 0.34, + shafts = 0.55, + grade_tint = float3(1.10, 1.02, 0.84), + grade_amount = 0.16, + name = "SUN FLATS" + ) +} + +def private env_forest() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(-0.30, 0.92, 0.25)), + sun_color = float3(1.10, 1.16, 0.96), + sky_color = float3(0.42, 0.52, 0.58), + horizon_color = float3(0.66, 0.72, 0.64), + bounce_color = float3(0.12, 0.20, 0.13), + fog_color = float3(0.62, 0.70, 0.63), + fog_density = 0.0105, + river_color = float3(0.05, 0.22, 0.26), + bank_color = float3(0.16, 0.36, 0.14), + shore_color = float3(0.44, 0.42, 0.34), + rock_color = float3(0.28, 0.30, 0.28), + foliage_tint = float3(0.90, 1.0, 0.88), + night = 0.0, + exposure = 1.18, + bloom = 0.30, + shafts = 1.10, + grade_tint = float3(0.92, 1.02, 0.94), + grade_amount = 0.18, + name = "GREEN NARROWS" + ) +} + +def private env_night() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(-0.26, 0.94, 0.21)), + sun_color = float3(0.52, 0.64, 1.00), + sky_color = float3(0.026, 0.040, 0.090), + horizon_color = float3(0.12, 0.16, 0.30), + bounce_color = float3(0.055, 0.075, 0.130), + fog_color = float3(0.10, 0.13, 0.23), + fog_density = 0.0095, + river_color = float3(0.020, 0.055, 0.135), + // Moonlit ground still has to read as ground: at the first pass's values + // the banks went black and the player could not see the channel edge. + bank_color = float3(0.19, 0.24, 0.28), + shore_color = float3(0.30, 0.33, 0.38), + rock_color = float3(0.24, 0.26, 0.30), + foliage_tint = float3(0.60, 0.72, 0.92), + night = 1.0, + exposure = 1.40, + bloom = 0.72, + shafts = 0.35, + grade_tint = float3(0.80, 0.90, 1.18), + grade_amount = 0.26, + name = "MIDNIGHT RUN" + ) +} + +def private env_volcanic() : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(float3(0.32, 0.94, 0.115)), + sun_color = float3(1.95, 0.92, 0.44), + sky_color = float3(0.16, 0.09, 0.13), + horizon_color = float3(0.88, 0.34, 0.14), + bounce_color = float3(0.26, 0.09, 0.04), + fog_color = float3(0.55, 0.28, 0.19), + fog_density = 0.0120, + river_color = float3(0.16, 0.13, 0.16), + bank_color = float3(0.30, 0.15, 0.11), + shore_color = float3(0.24, 0.17, 0.15), + rock_color = float3(0.18, 0.14, 0.14), + foliage_tint = float3(1.0, 0.78, 0.62), + night = 0.35, + exposure = 1.10, + bloom = 0.66, + shafts = 1.35, + grade_tint = float3(1.18, 0.88, 0.76), + grade_amount = 0.28, + name = "ASH CHANNEL" + ) +} + +def section_env(idx : int) : SectionEnv { + let biome = clamp(idx, 0, MAX_SECTIONS - 1) % 5 + if (biome == 0) { + return <- env_dawn() + } elif (biome == 1) { + return <- env_desert() + } elif (biome == 2) { + return <- env_forest() + } elif (biome == 3) { + return <- env_night() + } + return <- env_volcanic() +} + +def lerp_env(a, b : SectionEnv; t : float) : SectionEnv { + return <- SectionEnv( + sun_dir = normalize(lerp(a.sun_dir, b.sun_dir, t)), + sun_color = lerp(a.sun_color, b.sun_color, t), + sky_color = lerp(a.sky_color, b.sky_color, t), + horizon_color = lerp(a.horizon_color, b.horizon_color, t), + bounce_color = lerp(a.bounce_color, b.bounce_color, t), + fog_color = lerp(a.fog_color, b.fog_color, t), + fog_density = lerp(a.fog_density, b.fog_density, t), + river_color = lerp(a.river_color, b.river_color, t), + bank_color = lerp(a.bank_color, b.bank_color, t), + shore_color = lerp(a.shore_color, b.shore_color, t), + rock_color = lerp(a.rock_color, b.rock_color, t), + foliage_tint = lerp(a.foliage_tint, b.foliage_tint, t), + night = lerp(a.night, b.night, t), + exposure = lerp(a.exposure, b.exposure, t), + bloom = lerp(a.bloom, b.bloom, t), + shafts = lerp(a.shafts, b.shafts, t), + grade_tint = lerp(a.grade_tint, b.grade_tint, t), + grade_amount = lerp(a.grade_amount, b.grade_amount, t), + name = (t < 0.5 ? a.name : b.name) + ) +} + +var @live section_color_blend_t = 1.0 +var env_from : SectionEnv +var env_to : SectionEnv +var env_now : SectionEnv + +def refresh_env() { + env_now <- lerp_env(env_from, env_to, saturate(section_color_blend_t)) +} + +def set_env_immediate(idx : int) { + env_from <- section_env(idx) + env_to <- section_env(idx) + section_color_blend_t = 1.0 + refresh_env() +} + +def begin_env_transition(idx : int) { + env_from <- env_now + env_to <- section_env(idx) + section_color_blend_t = 0.0 +} + +// --- GL Object Handles --- + +var geo_sphere : OpenGLGeometryFragment +var geo_cube : OpenGLGeometryFragment +var geo_plane_xz : OpenGLGeometryFragment +var geo_cylinder : OpenGLGeometryFragment +var geo_cone : OpenGLGeometryFragment +var geo_prism : OpenGLGeometryFragment +var geo_disc : OpenGLGeometryFragment +var geo_heli : OpenGLGeometryFragment +var geo_heli_tail : OpenGLGeometryFragment +var geo_gunboat : OpenGLGeometryFragment +var geo_jet : OpenGLGeometryFragment +var geo_shards : array +var geo_flora : array +var hud_font : Font? +var river_bank_geo : OpenGLGeometryFragment +var river_surface_geo : OpenGLGeometryFragment + +// --- Helpers --- + +def random_f() : float { + global_rng_seed = global_rng_seed * 1103515245u + 12345u + return float(int(global_rng_seed >> 16u) % 1000) / 1000.0 +} + +def random_range(lo, hi : float) : float { + return lo + random_f() * (hi - lo) +} + +def random_sign() : float { + return (random_f() < 0.5 ? -1.0 : 1.0) +} + +def section_idx() : int { + return min(current_section, MAX_SECTIONS - 1) +} + +def get_river_color() : float3 { + return env_now.river_color +} + +def get_bank_color() : float3 { + return env_now.bank_color +} + +def get_fog_color() : float3 { + return env_now.fog_color +} + +def section_speed_mult() : float { + return 1.0 + float(current_section) * 0.15 +} + +def add_screen_shake(amount : float) { + screen_shake_amount = max(screen_shake_amount, amount) +} + +def fuel_bar_color() : float3 { + let low = player_fuel < LOW_FUEL_THRESHOLD + let blink = low && int(get_uptime() * 6.0) % 2 == 0 + return ( + low ? (blink ? float3(1.0, 0.2, 0.2) : float3(1.0, 0.5, 0.1)) : float3(0.25, 0.95, 0.42) + ) +} + +// Screen-space scale so the HUD keeps its designed proportions on a HiDPI +// framebuffer. display_w/display_h are framebuffer pixels, not window points -- +// on a retina display that is 2x, which is why the old fixed-pixel HUD came out +// half size. +def hud_scale() : float { + return max(float(display_h) / 720.0, 0.5) +} diff --git a/web/examples/ui/samples/examples/river_run/rr_models.das b/web/examples/ui/samples/examples/river_run/rr_models.das new file mode 100644 index 0000000000..1830bd3b3a --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/rr_models.das @@ -0,0 +1,547 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require rr_shaders public + +// Composite meshes for the things the player actually looks at. +// +// Each model is welded from transformed primitives into ONE GeometryFragment, +// with the part index baked into uv.x and a gloss modifier into uv.y. The prop +// shader resolves uv.x against four tint uniforms, so a whole painted hull is a +// single draw call and an enemy is the same mesh under a different palette. +// +// Local frames are nose-forward (+y), up (+z), and roughly unit-scaled, so a +// call site scales by one radius and gets sane proportions. + +// Part slots, shared by every model: 0 body, 1 canopy/accent, 2 metal, 3 dark. +let PART_BODY = 0.0 +let PART_ACCENT = 1.0 +let PART_METAL = 2.0 +let PART_DARK = 3.0 + +let GLOSS_MATTE = 0.1 +let GLOSS_SEMI = 0.45 +let GLOSS_SHINY = 1.0 + +// Weld `src` into `dst` under (pos, rot, scale). Normals go through +// rotation * (n / scale), which is the inverse-transpose for a rot-scale pair -- +// without the division a stretched fuselage would light as if it were round. +def private weld(var dst : GeometryFragment; src : GeometryFragment; + pos, scale : float3; rot : float4; part, gloss : float) { + let base = length(dst.vertices) + let m = compose(pos, rot, scale) + let inv_scale = float3(1.0 / scale.x, 1.0 / scale.y, 1.0 / scale.z) + dst.vertices |> reserve(base + length(src.vertices)) + for (v in src.vertices) { + let p = m * float4(v.xyz, 1.0) + let n = m * float4(v.normal * inv_scale, 0.0) + dst.vertices |> push(GeometryPreviewVertex( + xyz = p.xyz, + normal = normalize(n.xyz), + uv = float2(part, gloss) + )) + } + dst.indices |> reserve(length(dst.indices) + length(src.indices)) + for (i in src.indices) { + dst.indices |> push(base + i) + } +} + +def private no_rot() : float4 { + return float4(0.0, 0.0, 0.0, 1.0) +} + +def private axis_rot(axis : float3; angle : float) : float4 { + let h = angle * 0.5 + let s = sin(h) + return float4(axis.x * s, axis.y * s, axis.z * s, cos(h)) +} + +// A flat disc in the XY plane with uv in [0,1]^2, used for the rotor sweep. +def gen_disc(segments : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + frag.vertices |> reserve(segments + 2) + frag.indices |> reserve(segments * 3) + // Fan centre first, then the rim. + frag.vertices |> push(GeometryPreviewVertex( + xyz = float3(0.0), + normal = float3(0.0, 0.0, 1.0), + uv = float2(0.5, 0.5) + )) + for (i in range(segments + 1)) { + let a = float(i) * (2.0 * PI / float(segments)) + let c = float2(cos(a), sin(a)) + frag.vertices |> push(GeometryPreviewVertex( + xyz = float3(c.x, c.y, 0.0), + normal = float3(0.0, 0.0, 1.0), + uv = c * 0.5 + float2(0.5) + )) + } + for (i in range(segments)) { + // One fan triangle per rim step: centre, this rim vertex, the next. + frag.indices |> push_from([0, i + 1, i + 2]) + } + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Helicopter +// ============================================================================ + +def gen_helicopter() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + var body <- gen_sphere(14, 10, false) + var tube <- gen_cylinder(GenDirection.xy, 12) + var box <- gen_cube() + + let y_axis = float3(0.0, 1.0, 0.0) + let x_axis = float3(1.0, 0.0, 0.0) + + // Fuselage: a stretched ellipsoid, widest at the cabin and tapering aft. + weld(frag, body, float3(0.0, 0.02, 0.30), float3(0.32, 0.58, 0.30), no_rot(), PART_BODY, GLOSS_SEMI) + // Chin / nose, dropped and pushed forward so the silhouette has a beak. + weld(frag, body, float3(0.0, 0.46, 0.24), float3(0.24, 0.26, 0.20), no_rot(), PART_BODY, GLOSS_SEMI) + // Canopy glass, sitting proud of the fuselage. + weld(frag, body, float3(0.0, 0.34, 0.40), float3(0.24, 0.30, 0.20), no_rot(), PART_ACCENT, GLOSS_SHINY) + + // Tail boom running aft, then the fin and stabiliser that read as a tail + // even at the distance the camera keeps. + weld(frag, tube, float3(0.0, -0.62, 0.32), float3(0.075, 0.075, 0.62), + axis_rot(x_axis, PI * 0.5), PART_BODY, GLOSS_SEMI) + weld(frag, box, float3(0.0, -1.12, 0.46), float3(0.035, 0.16, 0.22), no_rot(), PART_BODY, GLOSS_SEMI) + weld(frag, box, float3(0.0, -1.02, 0.34), float3(0.30, 0.09, 0.028), no_rot(), PART_BODY, GLOSS_SEMI) + // Tail-rotor hub on the left face of the fin. + weld(frag, tube, float3(-0.07, -1.14, 0.46), float3(0.05, 0.05, 0.05), + axis_rot(y_axis, PI * 0.5), PART_METAL, GLOSS_SHINY) + + // Engine deck and rotor mast. + weld(frag, box, float3(0.0, -0.10, 0.56), float3(0.15, 0.26, 0.09), no_rot(), PART_DARK, GLOSS_MATTE) + weld(frag, tube, float3(0.0, -0.02, 0.66), float3(0.05, 0.05, 0.10), no_rot(), PART_METAL, GLOSS_SHINY) + // Exhaust stub. + weld(frag, tube, float3(0.13, -0.30, 0.50), float3(0.045, 0.045, 0.11), + axis_rot(x_axis, PI * 0.5), PART_DARK, GLOSS_MATTE) + + // Landing skids: two rails on four struts. They give the hull a ground + // plane to sit against and read strongly in the shadow silhouette. + for (side in fixed_array(-1.0, 1.0)) { + weld(frag, tube, float3(side * 0.26, 0.02, 0.045), float3(0.028, 0.028, 0.44), + axis_rot(x_axis, PI * 0.5), PART_METAL, GLOSS_SEMI) + for (fore in fixed_array(-0.24, 0.22)) { + weld(frag, tube, float3(side * 0.20, fore, 0.15), float3(0.022, 0.022, 0.12), + axis_rot(y_axis, side * 0.42), PART_METAL, GLOSS_SEMI) + } + } + + delete body + delete tube + delete box + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Gunboat +// ============================================================================ + +def gen_gunboat() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + var box <- gen_cube() + var tube <- gen_cylinder(GenDirection.xy, 10) + var wedge <- gen_prism(GenDirection.xy) + var body <- gen_sphere(12, 8, false) + + let x_axis = float3(1.0, 0.0, 0.0) + let z_axis = float3(0.0, 0.0, 1.0) + + // Hull: a slab with a wedge bow, so the boat has a direction on the water. + weld(frag, box, float3(0.0, -0.10, 0.15), float3(0.30, 0.72, 0.15), no_rot(), PART_BODY, GLOSS_SEMI) + weld(frag, wedge, float3(0.0, 0.78, 0.15), float3(0.30, 0.34, 0.15), + axis_rot(z_axis, -PI * 0.5), PART_BODY, GLOSS_SEMI) + // Gunwale strip: a lighter band along the top edge that catches the sun. + weld(frag, box, float3(0.0, -0.10, 0.30), float3(0.31, 0.73, 0.025), no_rot(), PART_ACCENT, GLOSS_SEMI) + + // Deck house with a windscreen. + weld(frag, box, float3(0.0, -0.44, 0.46), float3(0.21, 0.26, 0.17), no_rot(), PART_ACCENT, GLOSS_SEMI) + weld(frag, box, float3(0.0, -0.19, 0.50), float3(0.17, 0.02, 0.10), no_rot(), PART_DARK, GLOSS_SHINY) + + // Forward turret and barrel -- the part that tells the player it shoots. + weld(frag, body, float3(0.0, 0.28, 0.38), float3(0.19, 0.19, 0.14), no_rot(), PART_METAL, GLOSS_SEMI) + weld(frag, tube, float3(0.0, 0.62, 0.42), float3(0.045, 0.045, 0.30), + axis_rot(x_axis, PI * 0.5), PART_METAL, GLOSS_SHINY) + + // Mast. + weld(frag, tube, float3(0.0, -0.60, 0.76), float3(0.018, 0.018, 0.16), no_rot(), PART_METAL, GLOSS_SEMI) + + delete box + delete tube + delete wedge + delete body + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Jet +// ============================================================================ + +def gen_jet() : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + var body <- gen_sphere(12, 9, false) + var tube <- gen_cylinder(GenDirection.xy, 10) + var cone <- gen_cone(GenDirection.xy, 10) + var wedge <- gen_prism(GenDirection.xy) + var box <- gen_cube() + + let x_axis = float3(1.0, 0.0, 0.0) + let z_axis = float3(0.0, 0.0, 1.0) + + // Fuselage plus a sharp nose cone. + weld(frag, body, float3(0.0, 0.0, 0.0), float3(0.15, 0.62, 0.15), no_rot(), PART_BODY, GLOSS_SEMI) + weld(frag, cone, float3(0.0, 0.80, 0.0), float3(0.13, 0.13, 0.34), + axis_rot(x_axis, -PI * 0.5), PART_BODY, GLOSS_SEMI) + + // Delta wings, swept back and slightly anhedral. + for (side in fixed_array(-1.0, 1.0)) { + weld(frag, wedge, float3(side * 0.46, -0.12, -0.02), float3(0.44, 0.34, 0.035), + axis_rot(z_axis, side * PI * 0.5), PART_BODY, GLOSS_SEMI) + // Wingtip pods read as ordnance and stop the wing from ending in a line. + weld(frag, tube, float3(side * 0.80, -0.06, -0.02), float3(0.035, 0.035, 0.16), + axis_rot(x_axis, PI * 0.5), PART_METAL, GLOSS_SEMI) + // Intakes. + weld(frag, box, float3(side * 0.20, 0.10, -0.05), float3(0.055, 0.20, 0.075), + no_rot(), PART_DARK, GLOSS_MATTE) + } + + // Canopy and twin tail fins. + weld(frag, body, float3(0.0, 0.20, 0.13), float3(0.10, 0.24, 0.09), no_rot(), PART_ACCENT, GLOSS_SHINY) + for (side in fixed_array(-1.0, 1.0)) { + weld(frag, box, float3(side * 0.13, -0.62, 0.16), float3(0.022, 0.16, 0.16), + axis_rot(float3(0.0, 1.0, 0.0), side * 0.22), PART_BODY, GLOSS_SEMI) + } + + // Exhaust nozzle; the call site drives its glow through the emissive slot. + weld(frag, tube, float3(0.0, -0.70, 0.0), float3(0.11, 0.11, 0.09), + axis_rot(x_axis, PI * 0.5), PART_DARK, GLOSS_MATTE) + + delete body + delete tube + delete cone + delete wedge + delete box + gen_bbox(frag) + return <- frag +} + +// ============================================================================ +// Palette binding +// ============================================================================ + +// Bind a four-slot palette for the next model draw. `tint` multiplies every +// slot, which is how the damage flash and the invulnerability pulse are applied +// without a second material. +def set_model_palette(body, accent, metal, dark : float3) { + f_PaletteOn = 1.0 + f_Tint0 = body + f_Tint1 = accent + f_Tint2 = metal + f_Tint3 = dark +} + +def clear_model_palette() { + f_PaletteOn = 0.0 +} + +// ============================================================================ +// Debris shards +// ============================================================================ +// +// Explosion chunks were unit cubes, which read as flying dice. A shard is a cube +// whose eight CORNERS are jittered -- corners, not vertices, so shared corners +// move together and the hull stays closed -- then re-emitted with flat per-face +// normals. The faceting is the point: an angular chunk catching the sun on one +// face reads as torn plating, where a smooth-shaded one reads as a pebble. + +let SHARD_VARIANTS = 6 + +def private shard_hash(i : int; salt : float) : float { + let h = sin(float(i) * 12.9898 + salt * 78.233) * 43758.545 + return h - floor(h) +} + +def private gen_shard(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + + // Eight jittered corners, plus a per-variant squash so the family spans + // plates, splinters and blocks rather than eight near-identical dice. + let squash = float3( + 0.55 + shard_hash(seed, 1.0) * 0.85, + 0.55 + shard_hash(seed, 2.0) * 0.85, + 0.35 + shard_hash(seed, 3.0) * 0.6 + ) + var corner : float3[8] + for (i in range(8)) { + let sx = ((i & 1) != 0 ? 1.0 : -1.0) + let sy = ((i & 2) != 0 ? 1.0 : -1.0) + let sz = ((i & 4) != 0 ? 1.0 : -1.0) + let jitter = float3( + shard_hash(seed * 8 + i, 11.0) - 0.5, + shard_hash(seed * 8 + i, 23.0) - 0.5, + shard_hash(seed * 8 + i, 37.0) - 0.5 + ) * 0.7 + corner[i] = (float3(sx, sy, sz) + jitter) * squash + } + + // Cube faces as corner-index quads, wound counter-clockwise from outside. + let faces = fixed_array( + int4(1, 3, 7, 5), // +x + int4(2, 0, 4, 6), // -x + int4(3, 2, 6, 7), // +y + int4(0, 1, 5, 4), // -y + int4(4, 5, 7, 6), // +z + int4(2, 3, 1, 0) // -z + ) + frag.vertices |> reserve(6 * 6) + frag.indices |> reserve(6 * 6) + for (f in faces) { + let a = corner[f.x] + let b = corner[f.y] + let c = corner[f.z] + let d = corner[f.w] + // One flat normal per triangle, so every facet catches the light on its + // own terms. + for (tri in fixed_array(int3(0, 1, 2), int3(0, 2, 3))) { + let p = fixed_array(a, b, c, d) + let v0 = p[tri.x] + let v1 = p[tri.y] + let v2 = p[tri.z] + let n = normalize(cross(v1 - v0, v2 - v0)) + let base = length(frag.vertices) + for (v in fixed_array(v0, v1, v2)) { + frag.vertices |> push(GeometryPreviewVertex(xyz = v, normal = n, uv = float2(0.0, 0.35))) + } + frag.indices |> push_from([base, base + 1, base + 2]) + } + } + gen_bbox(frag) + return <- frag +} + +def gen_shard_variant(i : int) : GeometryFragment { + return <- gen_shard(i % SHARD_VARIANTS) +} + +// ============================================================================ +// Bank flora +// ============================================================================ +// +// The banks were a cone on a stick, repeated. Two things made that read as +// cartoon: every tree was the same perfect cone, and a tree was the only thing +// growing. These are six variants of a shared silhouette vocabulary -- conifer, +// broadleaf, dead, bush, boulder, reeds -- each welded into ONE mesh so a +// crowded bank is still one draw call per prop. +// +// Foliage rides PART_BODY and PART_ACCENT, wood rides PART_DARK. PART_METAL is +// deliberately unused: the prop shader auto-reflects that slot, and a chrome +// tree trunk is not the goal. + +let FLORA_CONIFER = 0 +let FLORA_BROADLEAF = 1 +let FLORA_DEAD = 2 +let FLORA_BUSH = 3 +let FLORA_ROCK = 4 +let FLORA_REEDS = 5 +let FLORA_VARIANTS = 6 +// Distinct pre-generated meshes per kind. Four is enough that a bank does not +// read as a repeat, and cheap enough to build every one at load. +let FLORA_INSTANCES = 4 + +def private flora_hash(i : int; salt : float) : float { + let h = sin(float(i) * 45.164 + salt * 91.377) * 43758.545 + return h - floor(h) +} + +// A trunk that actually tapers, leans a little and is not perfectly round. +def private weld_trunk(var frag : GeometryFragment; src : GeometryFragment; + height, radius, lean : float; seed : int) { + let segs = 3 + for (i in range(segs)) { + let t0 = float(i) / float(segs) + let t1 = float(i + 1) / float(segs) + let r0 = radius * (1.0 - t0 * 0.55) + let mid = (t0 + t1) * 0.5 + let off = float3(lean * mid * mid, lean * 0.4 * mid * mid, 0.0) + weld(frag, src, off + float3(0.0, 0.0, height * (t0 + t1) * 0.5), + float3(r0, r0 * (0.85 + flora_hash(seed * 4 + i, 3.0) * 0.3), height * (t1 - t0) * 0.5), + no_rot(), PART_DARK, GLOSS_MATTE) + } +} + +def private gen_conifer(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var cone <- gen_cone(GenDirection.xy, 9) + var tube <- gen_cylinder(GenDirection.xy, 7) + + let lean = (flora_hash(seed, 1.0) - 0.5) * 0.22 + weld_trunk(frag, tube, 0.55, 0.075, lean, seed) + + // Four to six tiers, each nudged off-axis and rolled, so no two conifers + // present the same silhouette. + let tiers = 4 + int(flora_hash(seed, 2.0) * 2.99) + for (i in range(tiers)) { + let t = float(i) / float(max(tiers - 1, 1)) + let h = 0.9 - t * 0.34 + let r = 0.62 - t * 0.30 + let jitter = float3( + (flora_hash(seed * 7 + i, 4.0) - 0.5) * 0.12, + (flora_hash(seed * 7 + i, 5.0) - 0.5) * 0.12, + 0.0 + ) + let z = 0.34 + t * 1.05 + h * 0.5 + let part = (i % 2 == 0 ? PART_BODY : PART_ACCENT) + weld(frag, cone, jitter + float3(lean * 0.8, lean * 0.3, z), + float3(r, r * (0.88 + flora_hash(seed * 7 + i, 6.0) * 0.24), h * 0.5), + axis_rot(float3(0.0, 0.0, 1.0), flora_hash(seed * 7 + i, 7.0) * 6.28), + part, GLOSS_MATTE) + } + delete cone + delete tube + gen_bbox(frag) + return <- frag +} + +def private gen_broadleaf(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var blob <- gen_sphere(10, 7, false) + var tube <- gen_cylinder(GenDirection.xy, 7) + + let lean = (flora_hash(seed, 1.0) - 0.5) * 0.3 + weld_trunk(frag, tube, 0.85, 0.085, lean, seed) + + // Crown built from overlapping squashed blobs rather than one ball: the + // lumpy union is what stops it reading as a lollipop. + let blobs = 4 + int(flora_hash(seed, 2.0) * 2.99) + for (i in range(blobs)) { + let a = flora_hash(seed * 11 + i, 3.0) * 6.28 + let rad = 0.16 + flora_hash(seed * 11 + i, 4.0) * 0.26 + let up = 1.02 + flora_hash(seed * 11 + i, 5.0) * 0.42 + let size = 0.32 + flora_hash(seed * 11 + i, 6.0) * 0.24 + let part = (i % 3 == 0 ? PART_ACCENT : PART_BODY) + weld(frag, blob, + float3(cos(a) * rad + lean, sin(a) * rad + lean * 0.4, up), + float3(size, size * (0.85 + flora_hash(seed * 11 + i, 7.0) * 0.3), size * 0.82), + no_rot(), part, GLOSS_MATTE) + } + delete blob + delete tube + gen_bbox(frag) + return <- frag +} + +def private gen_deadtree(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var tube <- gen_cylinder(GenDirection.xy, 6) + + let lean = (flora_hash(seed, 1.0) - 0.5) * 0.4 + weld_trunk(frag, tube, 1.25, 0.075, lean, seed) + + // Bare branches angled off the trunk. A few dead trees among the green ones + // do more for "this bank is a place" than another dozen conifers. + let branches = 3 + int(flora_hash(seed, 2.0) * 2.99) + for (i in range(branches)) { + let a = flora_hash(seed * 13 + i, 3.0) * 6.28 + let up = 0.55 + flora_hash(seed * 13 + i, 4.0) * 0.7 + let len = 0.22 + flora_hash(seed * 13 + i, 5.0) * 0.3 + let tilt = 0.5 + flora_hash(seed * 13 + i, 6.0) * 0.5 + let dir = float3(cos(a), sin(a), 0.0) + weld(frag, tube, dir * len * 0.6 + float3(lean, lean * 0.4, up), + float3(0.03, 0.03, len), + axis_rot(normalize(float3(-dir.y, dir.x, 0.0)), tilt), + PART_DARK, GLOSS_MATTE) + } + delete tube + gen_bbox(frag) + return <- frag +} + +def private gen_bush(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var blob <- gen_sphere(9, 6, false) + let blobs = 3 + int(flora_hash(seed, 1.0) * 2.99) + for (i in range(blobs)) { + let a = flora_hash(seed * 17 + i, 2.0) * 6.28 + let rad = flora_hash(seed * 17 + i, 3.0) * 0.3 + let size = 0.26 + flora_hash(seed * 17 + i, 4.0) * 0.2 + let part = (i % 2 == 0 ? PART_BODY : PART_ACCENT) + weld(frag, blob, float3(cos(a) * rad, sin(a) * rad, size * 0.72), + float3(size, size * 0.9, size * 0.66), no_rot(), part, GLOSS_MATTE) + } + delete blob + gen_bbox(frag) + return <- frag +} + +def private gen_boulder(seed : int) : GeometryFragment { + // Boulders reuse the debris shard: an irregular flat-shaded hull is exactly + // what a rock wants, and it costs nothing extra. + var frag <- gen_shard(seed + 3) + for (v in frag.vertices) { + v.xyz = float3(v.xyz.x, v.xyz.y, v.xyz.z * 0.6 + 0.55) + v.uv = float2(PART_DARK, GLOSS_MATTE) + } + gen_bbox(frag) + return <- frag +} + +def private gen_reeds(seed : int) : GeometryFragment { + var frag : GeometryFragment + frag.prim = GeometryFragmentType.triangles + var cone <- gen_cone(GenDirection.xy, 5) + // Stalks fanning outward from a clump: they go right at the waterline, + // which is the one place the old banks had nothing at all. + let stalks = 7 + int(flora_hash(seed, 1.0) * 6.99) + for (i in range(stalks)) { + let a = flora_hash(seed * 19 + i, 2.0) * 6.28 + let rad = flora_hash(seed * 19 + i, 3.0) * 0.26 + let h = 0.42 + flora_hash(seed * 19 + i, 4.0) * 0.5 + let tilt = (flora_hash(seed * 19 + i, 5.0) - 0.5) * 0.5 + let dir = float3(cos(a), sin(a), 0.0) + let part = (i % 3 == 0 ? PART_ACCENT : PART_BODY) + weld(frag, cone, dir * rad + float3(0.0, 0.0, h * 0.5), + float3(0.035, 0.035, h * 0.5), + axis_rot(normalize(float3(-dir.y, dir.x, 0.0)), tilt), + part, GLOSS_MATTE) + } + delete cone + gen_bbox(frag) + return <- frag +} + +// `variant` is a reserved word in gen2, hence `kind`. +def gen_flora(kind, seed : int) : GeometryFragment { + if (kind == FLORA_CONIFER) { + return <- gen_conifer(seed) + } elif (kind == FLORA_BROADLEAF) { + return <- gen_broadleaf(seed) + } elif (kind == FLORA_DEAD) { + return <- gen_deadtree(seed) + } elif (kind == FLORA_BUSH) { + return <- gen_bush(seed) + } elif (kind == FLORA_ROCK) { + return <- gen_boulder(seed) + } + return <- gen_reeds(seed) +} diff --git a/web/examples/ui/samples/examples/river_run/rr_postfx.das b/web/examples/ui/samples/examples/river_run/rr_postfx.das new file mode 100644 index 0000000000..964fcf148e --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/rr_postfx.das @@ -0,0 +1,743 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require opengl/opengl_boost public +require opengl/opengl_cache public +// No live_vars here: without it `@live` is inert metadata, which is what the +// playground wants -- there is no reload to persist across. +require daslib/math_boost public +require daslib/safe_addr public + +// Offscreen render targets and the screen-space chain that turns the raw scene +// pass into the finished frame: ambient occlusion, bloom, sun shafts, filmic +// tone mapping, vignette, grain and FXAA. +// +// Everything is held to GLSL ES 3.00 / WebGL2. The one capability that is not +// guaranteed there is a float-renderable colour attachment, so the HDR targets +// are probed at creation with glCheckFramebufferStatus and silently fall back to +// RGBA8 (bloom then works off a pre-exposed LDR buffer -- dimmer, never broken). + +let SHADOW_SIZE = 2048 +let BLOOM_LEVELS = 4 + +// Render-target handles live across a live reload, so init() reuses them +// instead of orphaning a set of GL objects the reloaded script can no longer +// name. +var @live fx_width = 0 +var @live fx_height = 0 +var @live fx_hdr = false + +var @live scene_fbo = 0u +var @live scene_color = 0u +var @live scene_nd = 0u +var @live scene_depth_rb = 0u + +var @live shadow_fbo = 0u +var @live shadow_tex = 0u + +var @live ldr_fbo = 0u +var @live ldr_tex = 0u + +var @live ao_fbo = 0u +var @live ao_tex = 0u +var @live ao_blur_fbo = 0u +var @live ao_blur_tex = 0u + +var @live shaft_fbo = 0u +var @live shaft_tex = 0u +var @live occl_fbo = 0u +var @live occl_tex = 0u + +var @live bloom_fbo : array +var @live bloom_tex : array +var @live bloom_w : array +var @live bloom_h : array + +var @live empty_vao = 0u + +// ============================================================================ +// Post-process shader interface +// ============================================================================ + +var @inout p_uv : float2 + +var @uniform @stage = 0 p_tex0 : sampler2D +var @uniform @stage = 1 p_tex1 : sampler2D +var @uniform @stage = 2 p_tex2 : sampler2D +var @uniform p_texel : float2 +var @uniform p_params : float4 +var @uniform p_params2 : float4 +var @uniform p_tint : float3 + +var @out p_FragColor : float4 + +// One attributeless triangle covering the viewport; cheaper than a quad and +// avoids the diagonal seam. +[vertex_program] +def vs_post { + let id = gl_VertexIndex + p_uv = float2(float((id << 1) & 2), float(id & 2)) + gl_Position = float4(p_uv * 2.0 - float2(1.0), 0.0, 1.0) +} + +// --- Bright pass: soft-knee threshold, so highlights ramp in instead of popping + +[fragment_program] +def fs_bright { + let c = texture(p_tex0, p_uv).xyz + let luma = dot(c, float3(0.2126, 0.7152, 0.0722)) + let threshold = p_params.x + let knee = p_params.y + let soft = clamp(luma - threshold + knee, 0.0, 2.0 * knee) + let contrib = max(soft * soft / (4.0 * knee + 0.0001), luma - threshold) / max(luma, 0.0001) + p_FragColor = float4(c * contrib, 1.0) +} + +// --- Progressive downsample (13-tap box, the standard dual-filter kernel) + +[fragment_program] +def fs_down { + let t = p_texel + var sum = texture(p_tex0, p_uv).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(-t.x, -t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(0.0, -t.y)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(-t.x, 0.0)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(t.x, 0.0)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(0.0, t.y)).xyz * 0.125 + sum += texture(p_tex0, p_uv + float2(t.x, t.y)).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(-t.x, -t.y) * 0.5).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(t.x, -t.y) * 0.5).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(-t.x, t.y) * 0.5).xyz * 0.0625 + sum += texture(p_tex0, p_uv + float2(t.x, t.y) * 0.5).xyz * 0.0625 + p_FragColor = float4(sum, 1.0) +} + +// --- Tent upsample, additively blended onto the next larger level + +// p_params.x is the filter RADIUS, p_params.y the output intensity. They have to +// stay separate: folding intensity into the radius makes a strength of zero a +// zero-width tap that still contributes at full brightness. +[fragment_program] +def fs_up { + let t = p_texel * p_params.x + var sum = texture(p_tex0, p_uv).xyz * 4.0 + sum += texture(p_tex0, p_uv + float2(-t.x, 0.0)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(t.x, 0.0)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(0.0, -t.y)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(0.0, t.y)).xyz * 2.0 + sum += texture(p_tex0, p_uv + float2(-t.x, -t.y)).xyz + sum += texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz + sum += texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz + sum += texture(p_tex0, p_uv + float2(t.x, t.y)).xyz + p_FragColor = float4(sum * (1.0 / 16.0) * p_params.y, 1.0) +} + +// --- Ambient occlusion +// +// Hemisphere sampling in view space, reconstructed from the linear depth packed +// in the scene normal target's alpha. Half resolution; the blur below hides the +// sample noise. + +def private view_from_uv(uv : float2; depth : float) : float3 { + let ndc = uv * 2.0 - float2(1.0) + return float3(ndc.x * p_params2.x * depth, ndc.y * p_params2.y * depth, -depth) +} + +[fragment_program] +def fs_ssao { + let nd = texture(p_tex0, p_uv) + let depth = nd.w * p_params2.z + if (nd.w >= 0.999) { + p_FragColor = float4(1.0) + return + } + let origin = view_from_uv(p_uv, depth) + let nrm = normalize(nd.xyz * 2.0 - float3(1.0)) + let radius = p_params.x + let rot = hash_noise(p_uv * p_params.z) * 6.2831853 + + var occlusion = 0.0 + let taps = 12 + for (i in range(taps)) { + let fi = float(i) + // Golden-angle spiral, rotated per pixel, with the radius growing as + // sqrt so samples stay area-uniform across the hemisphere. + let ang = rot + fi * 2.399963 + let rad = radius * sqrt((fi + 0.5) / float(taps)) + let dir = float3(cos(ang) * rad, sin(ang) * rad, 0.0) + var sample_pos = origin + dir + sample_pos += nrm * rad * 0.65 + + let clip = p_params2.w + let su = float2(sample_pos.x / (p_params2.x * -sample_pos.z), sample_pos.y / (p_params2.y * -sample_pos.z)) + let suv = su * 0.5 + float2(0.5) + if (suv.x < 0.0 || suv.x > 1.0 || suv.y < 0.0 || suv.y > 1.0) { + continue + } + let sample_depth = texture(p_tex0, suv).w * p_params2.z + let delta = -sample_pos.z - sample_depth + let range_check = saturate(radius / max(abs(-origin.z - sample_depth), 0.0001)) + if (delta > 0.02 * clip) { + occlusion += range_check + } + } + let ao = saturate(1.0 - occlusion / float(taps) * p_params.y) + p_FragColor = float4(ao, ao, ao, 1.0) +} + +def private hash_noise(p : float2) : float { + return (p.x * 0.0 + fract_local(sin(dot(floor(p), float2(12.9898, 78.233))) * 43758.545)) +} + +def private fract_local(x : float) : float { + return x - floor(x) +} + +// --- Cross-shaped AO blur (cheap, and the AO signal is already low frequency) + +[fragment_program] +def fs_ao_blur { + var sum = 0.0 + for (y in range(-2, 3)) { + for (x in range(-2, 3)) { + sum += texture(p_tex0, p_uv + float2(float(x), float(y)) * p_texel).x + } + } + p_FragColor = float4(float3(sum * (1.0 / 25.0)), 1.0) +} + +// --- Sun shafts +// +// Extract the parts of the frame that are sky (linear depth at the far plane) +// and near the sun on screen, then radially blur that buffer away from the sun +// position. Classic screen-space light scattering; no extra geometry. + +[fragment_program] +def fs_occlusion { + let nd = texture(p_tex1, p_uv) + let sky = step(0.995, nd.w) + let c = texture(p_tex0, p_uv).xyz + let d = distance(p_uv, p_params.xy) + let falloff = saturate(1.0 - d * p_params.z) + p_FragColor = float4(c * sky * falloff * falloff, 1.0) +} + +[fragment_program] +def fs_shafts { + let sun_uv = p_params.xy + let density = p_params.z + var uv = p_uv + let delta = (uv - sun_uv) * (density / 24.0) + var illum = 1.0 + var sum = float3(0.0) + for (_i in range(24)) { + uv -= delta + sum += texture(p_tex0, uv).xyz * illum + illum *= p_params.w + } + p_FragColor = float4(sum * (1.0 / 24.0), 1.0) +} + +// --- Composite: AO, bloom, shafts, exposure, ACES tone map, grade, vignette + +def private aces_tonemap(x : float3) : float3 { + let a = 2.51 + let b = 0.03 + let c = 2.43 + let d = 0.59 + let e = 0.14 + return saturate((x * (a * x + float3(b))) / (x * (c * x + float3(d)) + float3(e))) +} + +[fragment_program] +def fs_composite { + var color = texture(p_tex0, p_uv).xyz + let ao = texture(p_tex1, p_uv).x + let bloom = texture(p_tex2, p_uv).xyz + + // AO only darkens ambient-lit areas; applying it to the whole signal eats + // the sun highlights and reads as dirt. + color *= lerp(1.0, ao, p_params.z) + color += bloom * p_params.y + color *= p_params.x + + var mapped = aces_tonemap(color) + + // Grade: lift the shadows toward the section's fog colour, push contrast. + mapped = lerp(mapped, mapped * mapped * (float3(3.0) - 2.0 * mapped), p_params.w) + mapped = lerp(mapped, p_tint * dot(mapped, float3(0.2126, 0.7152, 0.0722)), p_params2.x) + + p_FragColor = float4(mapped, 1.0) +} + +// --- FXAA + vignette + grain, straight to the back buffer + +def private luma(c : float3) : float { + return dot(c, float3(0.299, 0.587, 0.114)) +} + +[fragment_program] +def fs_present { + let t = p_texel + let rgb_m = texture(p_tex0, p_uv).xyz + let l_nw = luma(texture(p_tex0, p_uv + float2(-t.x, -t.y)).xyz) + let l_ne = luma(texture(p_tex0, p_uv + float2(t.x, -t.y)).xyz) + let l_sw = luma(texture(p_tex0, p_uv + float2(-t.x, t.y)).xyz) + let l_se = luma(texture(p_tex0, p_uv + float2(t.x, t.y)).xyz) + let l_m = luma(rgb_m) + + let l_min = min(l_m, min(min(l_nw, l_ne), min(l_sw, l_se))) + let l_max = max(l_m, max(max(l_nw, l_ne), max(l_sw, l_se))) + + var dir = float2(-((l_nw + l_ne) - (l_sw + l_se)), ((l_nw + l_sw) - (l_ne + l_se))) + let reduce = max((l_nw + l_ne + l_sw + l_se) * 0.03125, 0.0078125) + let rcp = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce) + dir = clamp(dir * rcp, float2(-8.0), float2(8.0)) * t + + let rgb_a = 0.5 * (texture(p_tex0, p_uv + dir * (1.0 / 3.0 - 0.5)).xyz + + texture(p_tex0, p_uv + dir * (2.0 / 3.0 - 0.5)).xyz) + let rgb_b = rgb_a * 0.5 + 0.25 * (texture(p_tex0, p_uv + dir * -0.5).xyz + + texture(p_tex0, p_uv + dir * 0.5).xyz) + let l_b = luma(rgb_b) + var color = ((l_b < l_min) || (l_b > l_max)) ? rgb_a : rgb_b + + // Vignette, then a touch of chromatic falloff at the very edge. + let center = p_uv - float2(0.5) + let r2 = dot(center, center) + color *= 1.0 - saturate(r2 * p_params.x) * p_params.y + + // Animated grain, scaled down in the bright parts so it stays filmic. + let grain = fract_local(sin(dot(p_uv * p_params2.y + float2(p_params.w), float2(12.9898, 78.233))) * 43758.545) - 0.5 + color += float3(grain * p_params.z * (1.0 - luma(color) * 0.6)) + + p_FragColor = float4(color, 1.0) +} + +// ============================================================================ +// Program handles +// ============================================================================ + +var bright_program : uint +var down_program : uint +var up_program : uint +var ssao_program : uint +var ao_blur_program : uint +var occlusion_program : uint +var shafts_program : uint +var composite_program : uint +var present_program : uint + +def create_postfx_programs() { + bright_program = cache_shader_program(vs_post`shader_text, fs_bright`shader_text) + down_program = cache_shader_program(vs_post`shader_text, fs_down`shader_text) + up_program = cache_shader_program(vs_post`shader_text, fs_up`shader_text) + ssao_program = cache_shader_program(vs_post`shader_text, fs_ssao`shader_text) + ao_blur_program = cache_shader_program(vs_post`shader_text, fs_ao_blur`shader_text) + occlusion_program = cache_shader_program(vs_post`shader_text, fs_occlusion`shader_text) + shafts_program = cache_shader_program(vs_post`shader_text, fs_shafts`shader_text) + composite_program = cache_shader_program(vs_post`shader_text, fs_composite`shader_text) + present_program = cache_shader_program(vs_post`shader_text, fs_present`shader_text) +} + +// ============================================================================ +// Render targets +// ============================================================================ + +def private make_texture(w, h : int; internal_format : uint; format, data_type, filter : uint) : uint { + var tex = 0u + glGenTextures(1, safe_addr(tex)) + glBindTexture(GL_TEXTURE_2D, tex) + glTexImage2D(GL_TEXTURE_2D, 0, int(internal_format), w, h, 0, format, data_type, null) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, filter) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, filter) + // GLES 3.0 has no CLAMP_TO_BORDER, so every target clamps to edge and the + // shaders range-check explicitly where a border would have mattered. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) + return tex +} + +def private drop_texture(var tex : uint) { + if (tex != 0u) { + glDeleteTextures(1, unsafe(addr(tex))) + tex = 0u + } +} + +def private drop_fbo(var fbo : uint) { + if (fbo != 0u) { + glDeleteFramebuffers(1, unsafe(addr(fbo))) + fbo = 0u + } +} + +def private make_color_fbo(tex : uint) : uint { + var fbo = 0u + glGenFramebuffers(1, safe_addr(fbo)) + glBindFramebuffer(GL_FRAMEBUFFER, fbo) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0) + return fbo +} + +// Build the two-attachment scene target at the requested colour format and +// report whether the driver will actually render to it. RGBA16F is not a +// guaranteed colour-renderable format on WebGL2 (it needs EXT_color_buffer_float), +// so the caller retries at RGBA8 rather than assuming. +def private try_build_scene(w, h : int; color_format, color_type : uint) : bool { + scene_color = make_texture(w, h, color_format, GL_RGBA, color_type, GL_LINEAR) + scene_nd = make_texture(w, h, color_format, GL_RGBA, color_type, GL_NEAREST) + glGenRenderbuffers(1, safe_addr(scene_depth_rb)) + glBindRenderbuffer(GL_RENDERBUFFER, scene_depth_rb) + glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, w, h) + + glGenFramebuffers(1, safe_addr(scene_fbo)) + glBindFramebuffer(GL_FRAMEBUFFER, scene_fbo) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, scene_color, 0) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, scene_nd, 0) + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, scene_depth_rb) + var targets = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1) + glDrawBuffers(2, unsafe(addr(targets[0]))) + let ok = glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE + glBindFramebuffer(GL_FRAMEBUFFER, 0u) + if (!ok) { + drop_fbo(scene_fbo) + drop_texture(scene_color) + drop_texture(scene_nd) + glDeleteRenderbuffers(1, safe_addr(scene_depth_rb)) + scene_depth_rb = 0u + } + return ok +} + +def destroy_render_targets() { + drop_fbo(scene_fbo) + drop_texture(scene_color) + drop_texture(scene_nd) + if (scene_depth_rb != 0u) { + glDeleteRenderbuffers(1, safe_addr(scene_depth_rb)) + scene_depth_rb = 0u + } + drop_fbo(ldr_fbo) + drop_texture(ldr_tex) + drop_fbo(ao_fbo) + drop_texture(ao_tex) + drop_fbo(ao_blur_fbo) + drop_texture(ao_blur_tex) + drop_fbo(shaft_fbo) + drop_texture(shaft_tex) + drop_fbo(occl_fbo) + drop_texture(occl_tex) + for (fbo, tex in bloom_fbo, bloom_tex) { + drop_fbo(fbo) + drop_texture(tex) + } + bloom_fbo |> clear() + bloom_tex |> clear() + bloom_w |> clear() + bloom_h |> clear() + fx_width = 0 + fx_height = 0 +} + +def private ensure_shadow_target() { + if (shadow_fbo != 0u) { + return + } + shadow_tex = make_texture(SHADOW_SIZE, SHADOW_SIZE, GL_DEPTH_COMPONENT24, + GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, GL_LINEAR) + // Hardware PCF: the sampler returns the comparison result, so a single + // textureCompare tap is already 2x2 filtered. + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE) + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL) + + glGenFramebuffers(1, safe_addr(shadow_fbo)) + glBindFramebuffer(GL_FRAMEBUFFER, shadow_fbo) + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadow_tex, 0) + var none_target = fixed_array(0u) + glDrawBuffers(1, unsafe(addr(none_target[0]))) + glReadBuffer(uint(GL_NONE)) + glBindFramebuffer(GL_FRAMEBUFFER, 0u) +} + +def ensure_render_targets(w, h : int) { + if (w <= 0 || h <= 0) { + return + } + if (empty_vao == 0u) { + glGenVertexArrays(1, safe_addr(empty_vao)) + } + ensure_shadow_target() + if (w == fx_width && h == fx_height && scene_fbo != 0u) { + return + } + destroy_render_targets() + fx_width = w + fx_height = h + + fx_hdr = try_build_scene(w, h, GL_RGBA16F, GL_HALF_FLOAT) + if (!fx_hdr) { + to_log(LOG_WARNING, "river_run: no float colour attachment, post chain runs at 8 bits\n") + if (!try_build_scene(w, h, GL_RGBA8, GL_UNSIGNED_BYTE)) { + to_log(LOG_ERROR, "river_run: scene framebuffer is incomplete\n") + return + } + } + let color_format = (fx_hdr ? GL_RGBA16F : GL_RGBA8) + let color_type = (fx_hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE) + + ldr_tex = make_texture(w, h, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + ldr_fbo = make_color_fbo(ldr_tex) + + let aw = max(w / 2, 1) + let ah = max(h / 2, 1) + ao_tex = make_texture(aw, ah, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + ao_fbo = make_color_fbo(ao_tex) + ao_blur_tex = make_texture(aw, ah, GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, GL_LINEAR) + ao_blur_fbo = make_color_fbo(ao_blur_tex) + + let sw = max(w / 4, 1) + let sh = max(h / 4, 1) + occl_tex = make_texture(sw, sh, color_format, GL_RGBA, color_type, GL_LINEAR) + occl_fbo = make_color_fbo(occl_tex) + shaft_tex = make_texture(sw, sh, color_format, GL_RGBA, color_type, GL_LINEAR) + shaft_fbo = make_color_fbo(shaft_tex) + + var lw = w + var lh = h + bloom_tex |> reserve(BLOOM_LEVELS) + bloom_fbo |> reserve(BLOOM_LEVELS) + bloom_w |> reserve(BLOOM_LEVELS) + bloom_h |> reserve(BLOOM_LEVELS) + for (_i in range(BLOOM_LEVELS)) { + lw = max(lw / 2, 1) + lh = max(lh / 2, 1) + let tex = make_texture(lw, lh, color_format, GL_RGBA, color_type, GL_LINEAR) + bloom_tex |> push(tex) + bloom_fbo |> push(make_color_fbo(tex)) + bloom_w |> push(lw) + bloom_h |> push(lh) + } + glBindFramebuffer(GL_FRAMEBUFFER, 0u) +} + +// ============================================================================ +// Pass driver +// ============================================================================ + +def fullscreen_pass() { + glBindVertexArray(empty_vao) + glDrawArrays(GL_TRIANGLES, 0, 3) +} + +def private bind_target(fbo : uint; w, h : int) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo) + glViewport(0, 0, w, h) +} + +def begin_shadow_pass() { + bind_target(shadow_fbo, SHADOW_SIZE, SHADOW_SIZE) + glClear(GL_DEPTH_BUFFER_BIT) + glEnable(GL_DEPTH_TEST) + glDepthMask(true) + glDisable(GL_BLEND) + // Front-face culling in the shadow pass pushes the depth samples to the far + // side of each hull, which removes acne on the lit side for free. + glEnable(GL_CULL_FACE) + glCullFace(GL_FRONT) +} + +def end_shadow_pass() { + glCullFace(GL_BACK) +} + +def begin_scene_pass(clear_color : float3) { + bind_target(scene_fbo, fx_width, fx_height) + var targets = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1) + glDrawBuffers(2, unsafe(addr(targets[0]))) + glClearColor(clear_color.x, clear_color.y, clear_color.z, 1.0) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + glEnable(GL_DEPTH_TEST) + glDepthMask(true) + glDisable(GL_BLEND) +} + +// Alpha-blended geometry must not reach the normal/depth target -- blending +// into it would corrupt the AO and shaft reconstruction -- so the transparent +// span narrows the draw-buffer set to colour only. +def begin_transparent_span() { + var targets = fixed_array(GL_COLOR_ATTACHMENT0) + glDrawBuffers(1, unsafe(addr(targets[0]))) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glDepthMask(false) +} + +def end_transparent_span() { + glDepthMask(true) + glDisable(GL_BLEND) + var targets = fixed_array(GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1) + glDrawBuffers(2, unsafe(addr(targets[0]))) +} + +struct PostSettings { + exposure : float = 1.0 + bloom_threshold : float = 1.55 + bloom_knee : float = 0.35 + bloom_strength : float = 0.42 + ao_radius : float = 0.85 + ao_strength : float = 1.15 + ao_apply : float = 0.85 + shaft_uv : float2 = float2(0.5, 0.5) + shaft_on_screen : float = 0.0 + shaft_density : float = 0.85 + shaft_decay : float = 0.965 + shaft_strength : float = 0.65 + contrast : float = 0.22 + grade_tint : float3 = float3(1.0) + grade_amount : float = 0.0 + vignette : float = 1.35 + vignette_strength : float = 0.55 + grain : float = 0.035 + tan_half_fov : float2 = float2(0.5, 0.5) + z_far : float = 500.0 + time : float = 0.0 +} + +def private run_ssao(cfg : PostSettings) { + glUseProgram(ssao_program) + bind_target(ao_fbo, max(fx_width / 2, 1), max(fx_height / 2, 1)) + p_tex0 := scene_nd + p_params = float4(cfg.ao_radius, cfg.ao_strength, float(fx_width) * 0.37, 0.0) + p_params2 = float4(cfg.tan_half_fov.x, cfg.tan_half_fov.y, cfg.z_far, 1.0) + vs_post_bind_uniform(ssao_program) + fs_ssao_bind_uniform(ssao_program) + fullscreen_pass() + + glUseProgram(ao_blur_program) + bind_target(ao_blur_fbo, max(fx_width / 2, 1), max(fx_height / 2, 1)) + p_tex0 := ao_tex + p_texel = float2(2.0 / float(fx_width), 2.0 / float(fx_height)) + vs_post_bind_uniform(ao_blur_program) + fs_ao_blur_bind_uniform(ao_blur_program) + fullscreen_pass() +} + +def private run_bloom(cfg : PostSettings) { + glUseProgram(bright_program) + bind_target(bloom_fbo[0], bloom_w[0], bloom_h[0]) + p_tex0 := scene_color + p_params = float4(cfg.bloom_threshold, cfg.bloom_knee, 0.0, 0.0) + vs_post_bind_uniform(bright_program) + fs_bright_bind_uniform(bright_program) + fullscreen_pass() + + glUseProgram(down_program) + for (i in range(1, BLOOM_LEVELS)) { + bind_target(bloom_fbo[i], bloom_w[i], bloom_h[i]) + p_tex0 := bloom_tex[i - 1] + p_texel = float2(1.0 / float(bloom_w[i - 1]), 1.0 / float(bloom_h[i - 1])) + vs_post_bind_uniform(down_program) + fs_down_bind_uniform(down_program) + fullscreen_pass() + } + + // Additive tent upsample back down the chain; each level widens the halo. + glUseProgram(up_program) + glEnable(GL_BLEND) + glBlendFunc(uint(GL_ONE), uint(GL_ONE)) + for (step in range(BLOOM_LEVELS - 1)) { + let i = BLOOM_LEVELS - 1 - step + bind_target(bloom_fbo[i - 1], bloom_w[i - 1], bloom_h[i - 1]) + p_tex0 := bloom_tex[i] + p_texel = float2(1.0 / float(bloom_w[i]), 1.0 / float(bloom_h[i])) + p_params = float4(1.35, 1.0, 0.0, 0.0) + vs_post_bind_uniform(up_program) + fs_up_bind_uniform(up_program) + fullscreen_pass() + } + glDisable(GL_BLEND) +} + +def private run_shafts(cfg : PostSettings) { + let sw = max(fx_width / 4, 1) + let sh = max(fx_height / 4, 1) + + glUseProgram(occlusion_program) + bind_target(occl_fbo, sw, sh) + p_tex0 := scene_color + p_tex1 := scene_nd + p_params = float4(cfg.shaft_uv.x, cfg.shaft_uv.y, 0.9, 0.0) + vs_post_bind_uniform(occlusion_program) + fs_occlusion_bind_uniform(occlusion_program) + fullscreen_pass() + + glUseProgram(shafts_program) + bind_target(shaft_fbo, sw, sh) + p_tex0 := occl_tex + p_params = float4(cfg.shaft_uv.x, cfg.shaft_uv.y, cfg.shaft_density, cfg.shaft_decay) + vs_post_bind_uniform(shafts_program) + fs_shafts_bind_uniform(shafts_program) + fullscreen_pass() +} + +// Fold the shafts into the bloom buffer so the composite stays a three-texture +// read; they are both additive glows and share the same upsample filtering. +def private add_shafts_to_bloom(cfg : PostSettings) { + glUseProgram(up_program) + glEnable(GL_BLEND) + glBlendFunc(uint(GL_ONE), uint(GL_ONE)) + bind_target(bloom_fbo[0], bloom_w[0], bloom_h[0]) + p_tex0 := shaft_tex + p_texel = float2(4.0 / float(fx_width), 4.0 / float(fx_height)) + p_params = float4(1.2, cfg.shaft_strength * 1.6, 0.0, 0.0) + vs_post_bind_uniform(up_program) + fs_up_bind_uniform(up_program) + fullscreen_pass() + glDisable(GL_BLEND) +} + +def run_postfx(cfg : PostSettings) { + if (scene_fbo == 0u) { + return + } + glDisable(GL_DEPTH_TEST) + glDisable(GL_CULL_FACE) + glDepthMask(false) + + run_ssao(cfg) + run_bloom(cfg) + if (cfg.shaft_on_screen > 0.0) { + run_shafts(cfg) + add_shafts_to_bloom(cfg) + } + + glUseProgram(composite_program) + bind_target(ldr_fbo, fx_width, fx_height) + p_tex0 := scene_color + p_tex1 := ao_blur_tex + p_tex2 := bloom_tex[0] + p_params = float4(cfg.exposure, cfg.bloom_strength, cfg.ao_apply, cfg.contrast) + p_params2 = float4(cfg.grade_amount, 0.0, 0.0, 0.0) + p_tint = cfg.grade_tint + vs_post_bind_uniform(composite_program) + fs_composite_bind_uniform(composite_program) + fullscreen_pass() + + glUseProgram(present_program) + glBindFramebuffer(GL_FRAMEBUFFER, 0u) + glViewport(0, 0, fx_width, fx_height) + p_tex0 := ldr_tex + p_texel = float2(1.0 / float(fx_width), 1.0 / float(fx_height)) + p_params = float4(cfg.vignette, cfg.vignette_strength, cfg.grain, cfg.time) + p_params2 = float4(0.0, float(fx_height), 0.0, 0.0) + vs_post_bind_uniform(present_program) + fs_present_bind_uniform(present_program) + fullscreen_pass() + + glDepthMask(true) + glEnable(GL_DEPTH_TEST) + glEnable(GL_CULL_FACE) +} diff --git a/web/examples/ui/samples/examples/river_run/rr_shaders.das b/web/examples/ui/samples/examples/river_run/rr_shaders.das new file mode 100644 index 0000000000..7634ad3bb6 --- /dev/null +++ b/web/examples/ui/samples/examples/river_run/rr_shaders.das @@ -0,0 +1,682 @@ +options gen2 +options persistent_heap +options indenting = 4 + +require glfw/glfw_boost public +require opengl/opengl_boost public +require opengl/opengl_gen public +require opengl/opengl_cache public +require daslib/math_boost public +require daslib/safe_addr public + +// Scene shading for River Run. +// +// Every surface goes through one deferred-lit forward pass that writes two +// targets: HDR colour, and view-space normal + linear depth for the screen-space +// passes in rr_postfx. The lighting model is a low-cost analytic environment -- +// one sun with a shadow map, a sky hemisphere, and a bounce term tinted by the +// water -- so the section palettes can drive a whole time-of-day per section +// without any authored art. +// +// The shader DSL emits `#version 300 es` on emscripten, so everything here is +// held to the GLSL ES 3.00 / WebGL2 feature set: no compute, no textureGather, +// MRT through `@out @location`, shadow compare through sampler2DShadow. + +// --- Vertex interface (matches opengl_gen's PreviewVertex layout) --- + +var @in @location = 0 v_position : float3 +var @in @location = 1 v_normal : float3 +var @in @location = 2 v_texture : float2 + +var @uniform v_model : float4x4 +var @uniform v_view : float4x4 +var @uniform v_projection : float4x4 +var @uniform v_light_vp : float4x4 + +// --- Varyings --- + +var @inout f_world : float3 +var @inout f_normal : float3 +var @inout f_uv : float2 +var @inout f_view_pos : float3 +var @inout f_ray : float3 + +// --- Environment (host sets these once per frame) --- + +var @uniform f_SunDir : float3 // unit vector pointing TOWARD the sun +var @uniform f_SunColor : float3 +var @uniform f_SkyColor : float3 // zenith +var @uniform f_HorizonColor : float3 +var @uniform f_BounceColor : float3 // light kicked back up off the water +var @uniform f_FogColor : float3 +var @uniform f_FogParams : float3 // x = density, y = height falloff, z = start distance +var @uniform f_CameraPos : float3 +var @uniform f_GameTime : float +var @uniform f_ZFar : float +var @uniform f_NightAmount : float // 0 = day, 1 = full night (stars, dimmer sun) + +// --- Camera basis, for the attributeless sky pass --- + +var @uniform f_CamRight : float3 +var @uniform f_CamUp : float3 +var @uniform f_CamFwd : float3 +var @uniform f_CamScale : float2 // (tan(fov/2) * aspect, tan(fov/2)) + +// --- Per-draw material --- + +var @uniform f_Color : float4 +var @uniform f_Material : float4 // x roughness, y spec strength, z emissive, w rim +// x reflectivity, y head-on bias. Reflectivity mixes in a sampled environment +// colour, which is what makes a surface read as metal rather than as painted +// plastic with a highlight on it. +var @uniform f_Material2 : float4 + +// Composite models (helicopter, gunboat, jet) bake a part index into uv.x and a +// gloss modifier into uv.y, so a whole multi-coloured hull is one draw call +// instead of one per painted panel. +var @uniform f_PaletteOn : float +var @uniform f_Tint0 : float3 +var @uniform f_Tint1 : float3 +var @uniform f_Tint2 : float3 +var @uniform f_Tint3 : float3 + +// --- Shadowing --- + +// Unit 4, not 0: the post chain and the font shader both own unit 0, and GL +// refuses to sample a colour texture through a sampler2DShadow ("bound to wrong +// sampler type (Depth) - using zero texture"), which silently drops shadows. +var @uniform @stage = 4 f_ShadowMap : sampler2DShadow +var @uniform f_ShadowTexel : float2 // 1 / shadow map size + +// --- Water / terrain extras --- + +var @uniform f_RiverColor : float4 +var @uniform f_BankColor : float4 +var @uniform f_ShoreColor : float3 // wet sand / gravel at the waterline +var @uniform f_RockColor : float3 // exposed rock on the steep upper slopes + +// --- Fragment outputs (MRT: HDR colour + view normal & linear depth) --- + +var @out @location = 0 f_FragColor : float4 +var @out @location = 1 f_FragNormalDepth : float4 + +// ============================================================================ +// Shared helpers +// ============================================================================ + +// math::fract and glsl_common::fract both match here, so spell the fractional +// part out; it lowers to the same GLSL either way. +def private frac1(x : float) : float { + return x - floor(x) +} + +def private frac2(p : float2) : float2 { + return p - floor(p) +} + +def private hash21(p : float2) : float { + return frac1(sin(dot(p, float2(127.1, 311.7))) * 43758.545) +} + +// Value noise with a smooth (cubic) interpolant. The bank shading uses this +// instead of the old floor()-quantized hash, which tiled the ground into a +// visible chessboard. +def private vnoise(p : float2) : float { + let i = floor(p) + let f = frac2(p) + let u = f * f * (float2(3.0) - 2.0 * f) + let a = hash21(i) + let b = hash21(i + float2(1.0, 0.0)) + let c = hash21(i + float2(0.0, 1.0)) + let d = hash21(i + float2(1.0, 1.0)) + return lerp(lerp(a, b, u.x), lerp(c, d, u.x), u.y) +} + +def private fbm(p : float2; octaves : int) : float { + var sum = 0.0 + var amp = 0.5 + var freq = p + for (_i in range(octaves)) { + sum += vnoise(freq) * amp + freq *= 2.02 + amp *= 0.5 + } + return sum +} + +// Height-attenuated exponential distance fog. Fading toward the horizon colour +// (not a flat grey) is what makes the far bank read as atmosphere rather than a +// clipped mesh. +def private apply_fog(color : float3; world : float3; view_dist : float) : float3 { + // Fog only starts biting past f_FogParams.z. Without that offset the haze + // greys out the play area itself, which is what made the first pass read as + // washed out rather than atmospheric. + let d = max(view_dist - f_FogParams.z, 0.0) + let height_falloff = exp(-max(world.z, 0.0) * f_FogParams.y) + let amount = saturate(1.0 - exp(-d * f_FogParams.x * height_falloff)) + return lerp(color, f_FogColor, amount) +} + +// 3x3 PCF against the single ortho cascade. The normal-facing slope bias keeps +// near-grazing bank geometry from acne without detaching contact shadows. +def private sun_shadow(world : float3; nrm : float3) : float { + let lp = v_light_vp * float4(world, 1.0) + var proj = lp.xyz / lp.w + proj = proj * 0.5 + float3(0.5) + if (proj.z > 1.0 || proj.x < 0.0 || proj.x > 1.0 || proj.y < 0.0 || proj.y > 1.0) { + return 1.0 + } + let ndl = saturate(dot(nrm, f_SunDir)) + let bias = lerp(0.0035, 0.0006, ndl) + var sum = 0.0 + for (y in range(-1, 2)) { + for (x in range(-1, 2)) { + let ofs = float2(float(x), float(y)) * f_ShadowTexel + sum += textureCompare(f_ShadowMap, proj.xy + ofs, proj.z - bias) + } + } + return sum * (1.0 / 9.0) +} + +// Key + sky hemisphere + water bounce, plus a Blinn specular lobe and a rim +// term. Roughness/spec/emissive/rim ride in f_Material so every prop shares one +// program and one pipeline state. +def private shade_surface(albedo : float3; nrm : float3; world : float3; shadow, spec_scale : float) : float3 { + let view_dir = normalize(f_CameraPos - world) + let ndl = saturate(dot(nrm, f_SunDir)) + + let key = f_SunColor * ndl * shadow + let sky_mix = saturate(nrm.z * 0.5 + 0.5) + let ambient = lerp(f_BounceColor, f_SkyColor, sky_mix) + + let roughness = max(f_Material.x, 0.04) + let shininess = 2.0 / (roughness * roughness) - 2.0 + let half_dir = normalize(f_SunDir + view_dir) + let spec = pow(saturate(dot(nrm, half_dir)), max(shininess, 1.0)) * f_Material.y * spec_scale * shadow * ndl + + let fresnel = pow(1.0 - saturate(dot(nrm, view_dir)), 4.0) + let rim = fresnel * f_Material.w * (f_SkyColor * 0.6 + f_HorizonColor * 0.4) + + return albedo * (key + ambient) + f_SunColor * spec + rim + albedo * f_Material.z +} + +def private encode_normal_depth(nrm : float3) : float4 { + let view_nrm = normalize(float3x3(v_view) * nrm) + return float4(view_nrm * 0.5 + float3(0.5), saturate(-f_view_pos.z / f_ZFar)) +} + +// A cheap environment probe for reflective surfaces: the sky gradient and the +// sun, without the cloud fbm or the star field sky_color pays for. On a small +// curved prop the clouds would not read anyway, and this runs per pixel on +// every metal surface in the frame. +def private sky_env(dir : float3) : float3 { + let up = dir.z + if (up < 0.0) { + // Reflecting downward: water and ground bounce, brightening toward the + // horizon. Without this a metal cylinder mirrors sky out of its + // underside. + return lerp(f_HorizonColor * 0.5, f_BounceColor * 2.2, saturate(-up * 1.6)) + } + let horizon_t = pow(1.0 - up, 6.0) + var col = lerp(f_SkyColor, f_HorizonColor, horizon_t) + let sun_dot = saturate(dot(dir, f_SunDir)) + col += f_SunColor * (pow(sun_dot, 12.0) * 0.45 + pow(sun_dot, 300.0) * 2.2) + return col +} + +// Procedural sky: zenith-to-horizon ramp, a haze band pinned at the horizon, a +// sun disc with a wide forward-scatter glow, drifting cloud fbm, and stars that +// fade in with f_NightAmount. +def private sky_color(dir : float3) : float3 { + let up = saturate(dir.z) + let horizon_t = pow(1.0 - up, 6.0) + var col = lerp(f_SkyColor, f_HorizonColor, horizon_t) + + let sun_dot = saturate(dot(dir, f_SunDir)) + let glow = pow(sun_dot, 8.0) * 0.35 + pow(sun_dot, 128.0) * 0.9 + col += f_SunColor * glow + let disc = smoothstep(0.9982, 0.9992, sun_dot) + col = lerp(col, f_SunColor * 3.0, disc * (1.0 - f_NightAmount * 0.85)) + + if (dir.z > -0.02) { + let plane = dir.xy / max(dir.z + 0.12, 0.02) + let drift = float2(f_GameTime * 0.012, f_GameTime * 0.004) + let cloud = fbm(plane * 1.9 + drift, 4) + let cover = smoothstep(0.46, 0.72, cloud) * smoothstep(0.0, 0.14, dir.z) + let cloud_lit = lerp(f_HorizonColor, f_SkyColor * 0.4 + f_SunColor * 0.8, saturate(sun_dot + 0.35)) + col = lerp(col, cloud_lit, cover * 0.55) + + // The visible sky is a narrow band, so the star field needs a dense grid + // to land more than a handful of cells in frame. + let star_grid = floor(plane * 95.0) + let star = hash21(star_grid) + let twinkle = 0.65 + 0.35 * sin(f_GameTime * 2.5 + star * 40.0) + let star_lit = smoothstep(0.976, 0.998, star) * twinkle + col += float3(star_lit * f_NightAmount * smoothstep(0.01, 0.16, dir.z)) * float3(0.9, 0.95, 1.0) + } + return col +} + +// ============================================================================ +// Sky — attributeless fullscreen triangle +// ============================================================================ + +[vertex_program] +def vs_sky { + let id = gl_VertexIndex + let uv = float2(float((id << 1) & 2), float(id & 2)) + let ndc = uv * 2.0 - float2(1.0) + f_ray = normalize(f_CamFwd + f_CamRight * (ndc.x * f_CamScale.x) + f_CamUp * (ndc.y * f_CamScale.y)) + gl_Position = float4(ndc, 1.0, 1.0) +} + +[fragment_program] +def fs_sky { + f_FragColor = float4(sky_color(normalize(f_ray)), 1.0) + f_FragNormalDepth = float4(0.5, 0.5, 1.0, 1.0) +} + +// ============================================================================ +// Shadow caster — depth only +// ============================================================================ + +[vertex_program] +def vs_shadow { + gl_Position = v_light_vp * (v_model * float4(v_position, 1.0)) +} + +[fragment_program] +def fs_shadow { + f_FragColor = float4(1.0) +} + +// ============================================================================ +// Props — hull, boats, jets, bridges, trees, houses, pickups +// ============================================================================ + +[vertex_program] +def vs_prop { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_normal = normalize(float3x3(v_model) * v_normal) + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_prop { + let nrm = normalize(f_normal) + var albedo = f_Color.xyz + if (f_PaletteOn > 0.5) { + // Nested step() selects, so the branch stays uniform across the quad. + var picked = f_Tint0 + picked = lerp(picked, f_Tint1, step(0.5, f_uv.x)) + picked = lerp(picked, f_Tint2, step(1.5, f_uv.x)) + picked = lerp(picked, f_Tint3, step(2.5, f_uv.x)) + albedo = picked * f_Color.xyz + } + let shadow = sun_shadow(f_world, nrm) + // uv.y is the part's gloss modifier; 0 reads matte, 1 reads lacquered. + let gloss = (f_PaletteOn > 0.5 ? 0.30 + f_uv.y * 1.6 : 1.0) + var lit = shade_surface(albedo, nrm, f_world, shadow, gloss) + + // Anything in the model palette's metal slot reflects by default, so a whole + // fleet reads as metal without every call site opting in. + var reflectivity = f_Material2.x + if (f_PaletteOn > 0.5) { + let is_metal = step(1.5, f_uv.x) * (1.0 - step(2.5, f_uv.x)) + reflectivity = max(reflectivity, is_metal * 0.5) + } + if (reflectivity > 0.002) { + let view_dir = normalize(f_CameraPos - f_world) + let env = sky_env(normalize(reflect(-view_dir, nrm))) + let fres = pow(1.0 - saturate(dot(nrm, view_dir)), 4.0) + // Metal tints what it reflects; the grazing term keeps flat-on faces + // from turning into mirrors. + let mixed = env * lerp(float3(1.0), albedo, 0.65) + lit = lerp(lit, mixed, saturate(reflectivity * (f_Material2.y + (1.0 - f_Material2.y) * fres))) + } + + f_FragColor = float4(apply_fog(lit, f_world, length(f_view_pos)), f_Color.w) + f_FragNormalDepth = encode_normal_depth(nrm) +} + +// ============================================================================ +// Terrain — river banks and the centre island of a split +// +// uv.x carries distance from the water's edge in world units (packed by +// gen_river_banks), which drives the sand/grass/rock ramp and lets the shoreline +// stay put as the banks weave. +// ============================================================================ + +[vertex_program] +def vs_terrain { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_normal = normalize(float3x3(v_model) * v_normal) + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_terrain { + let shore_dist = f_uv.x + let p = f_world.xy + + let grain = fbm(p * 0.11, 4) + let macro = fbm(p * 0.021 + float2(37.0, 11.0), 3) + + // Shore and rock are their own palette entries. Deriving them from the bank + // colour, as the first pass did, produced a green beach in every biome. + let sand = f_ShoreColor * (0.86 + grain * 0.30) + let grass = f_BankColor.xyz * (0.78 + grain * 0.46) + let dry = f_BankColor.xyz * float3(1.16, 1.06, 0.78) + let rock = f_RockColor * (0.78 + grain * 0.36) + + // Wet-to-dry sand band right at the waterline, then grass, then rock on the + // steep upper slopes. + var albedo = lerp(sand * 0.72, sand, smoothstep(0.0, 1.1, shore_dist)) + albedo = lerp(albedo, grass, smoothstep(1.4, 4.5, shore_dist)) + albedo = lerp(albedo, dry, saturate(macro * 1.4 - 0.35) * smoothstep(6.0, 26.0, shore_dist)) + + let nrm = normalize(f_normal) + let slope = 1.0 - saturate(nrm.z) + albedo = lerp(albedo, rock, smoothstep(0.55, 0.88, slope)) + + let shadow = sun_shadow(f_world, nrm) + let lit = shade_surface(albedo, nrm, f_world, shadow, 1.0) + f_FragColor = float4(apply_fog(lit, f_world, length(f_view_pos)), 1.0) + f_FragNormalDepth = encode_normal_depth(nrm) +} + +// ============================================================================ +// Water +// +// Three crossing gerstner-ish ripples perturb the normal; the surface then gets +// a Fresnel-weighted sky reflection, a sharp sun glint, depth tinting, and a +// foam band whose width follows uv.x (distance to the nearest bank). +// ============================================================================ + +def private water_normal(p : float2; t : float) : float3 { + var d = float2(0.0) + var amp = 0.055 + var freq = 0.9 + var dir = float2(0.86, 0.5) + for (i in range(3)) { + let phase = dot(p, dir) * freq + t * (1.1 + float(i) * 0.45) + let slope = cos(phase) * amp * freq + d += dir * slope + amp *= 0.55 + freq *= 1.9 + dir = float2(dir.x * 0.28 - dir.y * 0.96, dir.x * 0.96 + dir.y * 0.28) + } + let ripple = (vnoise(p * 2.4 + float2(t * 0.35, -t * 0.22)) - 0.5) * 0.08 + d += float2(ripple, ripple * 0.6) + return normalize(float3(-d.x, -d.y, 1.0)) +} + +[vertex_program] +def vs_water { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_water { + let nrm = water_normal(f_world.xy, f_GameTime) + let view_dir = normalize(f_CameraPos - f_world) + let shore_dist = f_uv.x + + // Shallow water near the banks reads lighter and greener. + let depth_t = smoothstep(0.0, 5.5, shore_dist) + let shallow = f_RiverColor.xyz * 1.55 + float3(0.05, 0.11, 0.06) + let albedo = lerp(shallow, f_RiverColor.xyz * 0.72, depth_t) + + let fresnel = 0.02 + 0.98 * pow(1.0 - saturate(dot(nrm, view_dir)), 5.0) + let refl_dir = reflect(-view_dir, nrm) + let refl = sky_color(normalize(float3(refl_dir.xy, abs(refl_dir.z)))) + + let shadow = sun_shadow(f_world, float3(0.0, 0.0, 1.0)) + let ndl = saturate(dot(nrm, f_SunDir)) + let half_dir = normalize(f_SunDir + view_dir) + let glint = pow(saturate(dot(nrm, half_dir)), 900.0) * shadow + + var col = albedo * (f_SunColor * ndl * 0.35 * shadow + f_SkyColor * 0.55 + f_BounceColor * 0.25) + col = lerp(col, refl, saturate(fresnel * 0.85)) + col += f_SunColor * glint * 1.5 + + // Foam: a bright band hugging the bank, broken up by noise so it does not + // read as a drawn outline, plus a faint moving lace further out. + let foam_edge = 1.0 - smoothstep(0.0, 0.55, shore_dist) + let lace = vnoise(f_world.xy * 5.5 + float2(0.0, f_GameTime * 0.7)) + let churn = vnoise(f_world.xy * 1.7 - float2(f_GameTime * 0.25, 0.0)) + let foam = foam_edge * saturate(0.25 + lace * 1.1) * saturate(0.5 + churn) + col = lerp(col, float3(0.92, 0.96, 1.0) * (f_SkyColor * 0.5 + float3(0.45)), saturate(foam) * 0.8) + + f_FragColor = float4(apply_fog(col, f_world, length(f_view_pos)), 1.0) + f_FragNormalDepth = encode_normal_depth(nrm) +} + +// ============================================================================ +// Rotor — alpha-blended swept disc with per-blade streaks and motion smear +// ============================================================================ + +[vertex_program] +def vs_rotor { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_rotor { + let c = f_uv * 2.0 - float2(1.0) + let r = length(c) + if (r > 1.0) { + discard() + } + let ang = atan2(c.y, c.x) + + // Two blades smeared into a disc: a low-frequency angular ripple plus a + // solid inner haze that stands in for the blur of a spinning rotor. + let blades = 0.5 + 0.5 * cos(ang * 2.0 + f_Material.z * 26.0) + let smear = pow(blades, 3.0) * 0.55 + 0.18 + let ring = smoothstep(0.18, 0.42, r) * (1.0 - smoothstep(0.86, 1.0, r)) + let hub = 1.0 - smoothstep(0.0, 0.22, r) + + let alpha = f_Color.w * saturate(ring * smear + hub * 0.9) + let tint = f_Color.xyz * (0.75 + smear * 0.5) + f_SunColor * 0.12 + f_FragColor = float4(apply_fog(tint, f_world, length(f_view_pos)), alpha) +} + +// ============================================================================ +// Soft puff -- a camera-facing billboard with a radial falloff +// +// Smoke and fireballs were low-poly spheres, which gave every puff a hard +// faceted silhouette. A billboard with a soft edge is both cheaper and reads as +// a volume rather than a solid. The quad is built in VIEW space so it always +// faces the camera without the host computing a basis; f_Material carries the +// radius, the emissive and the spin angle. +// ============================================================================ + +[vertex_program] +def vs_puff { + let center = v_view * (v_model * float4(0.0, 0.0, 0.0, 1.0)) + let r = f_Material.y + let view_pos = center.xyz + float3(v_position.x * r, v_position.y * r, 0.0) + f_view_pos = view_pos + f_uv = v_texture + f_world = (v_model * float4(0.0, 0.0, 0.0, 1.0)).xyz + gl_Position = v_projection * float4(view_pos, 1.0) +} + +[fragment_program] +def fs_puff { + var c = f_uv * 2.0 - float2(1.0) + // Spin the sampling frame so repeated puffs do not read as clones. + let a = f_Material.w + let ca = cos(a) + let sa = sin(a) + c = float2(c.x * ca - c.y * sa, c.x * sa + c.y * ca) + let r2 = dot(c, c) + if (r2 > 1.0) { + discard() + } + // A lumpy edge, so the billboard does not read as a perfect circle. + let lump = 0.82 + 0.18 * vnoise(c * 2.4 + float2(f_Material.x * 7.3)) + let falloff = pow(saturate(1.0 - r2 / (lump * lump)), 1.7) + let alpha = f_Color.w * falloff + let col = f_Color.xyz * (1.0 + f_Material.z * falloff) + f_FragColor = float4(apply_fog(col, f_world, length(f_view_pos)), alpha) +} + +// ============================================================================ +// Shockwave ring -- a flat annulus on the water, drawn additively +// ============================================================================ + +[vertex_program] +def vs_ring { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + f_uv = v_texture + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_ring { + let c = f_uv * 2.0 - float2(1.0) + let r = length(c) + if (r > 1.0) { + discard() + } + // f_Material.x is the ring's normalised thickness; the band tightens and + // dims as the wave expands, which is what sells it as a travelling front. + let width = max(f_Material.x, 0.01) + let band = 1.0 - saturate(abs(1.0 - r) / width) + let alpha = f_Color.w * band * band + f_FragColor = float4(f_Color.xyz * (1.0 + f_Material.z), alpha) +} + +// ============================================================================ +// Unlit / emissive — bullets, particles, trails, HUD geometry +// ============================================================================ + +[vertex_program] +def vs_unlit { + let world = v_model * float4(v_position, 1.0) + f_world = world.xyz + let view = v_view * world + f_view_pos = view.xyz + gl_Position = v_projection * view +} + +[fragment_program] +def fs_unlit { + let col = f_Color.xyz * (1.0 + f_Material.z) + f_FragColor = float4(apply_fog(col, f_world, length(f_view_pos)), f_Color.w) +} + +// ============================================================================ +// Program handles + binding helpers +// ============================================================================ + +var sky_program : uint +var shadow_program : uint +var prop_program : uint +var terrain_program : uint +var water_program : uint +var rotor_program : uint +var puff_program : uint +var ring_program : uint +var unlit_program : uint +var active_program : uint + +def create_scene_programs() { + sky_program = cache_shader_program(vs_sky`shader_text, fs_sky`shader_text) + shadow_program = cache_shader_program(vs_shadow`shader_text, fs_shadow`shader_text) + prop_program = cache_shader_program(vs_prop`shader_text, fs_prop`shader_text) + terrain_program = cache_shader_program(vs_terrain`shader_text, fs_terrain`shader_text) + water_program = cache_shader_program(vs_water`shader_text, fs_water`shader_text) + rotor_program = cache_shader_program(vs_rotor`shader_text, fs_rotor`shader_text) + puff_program = cache_shader_program(vs_puff`shader_text, fs_puff`shader_text) + ring_program = cache_shader_program(vs_ring`shader_text, fs_ring`shader_text) + unlit_program = cache_shader_program(vs_unlit`shader_text, fs_unlit`shader_text) +} + +def use_prop_program() { + glUseProgram(prop_program) + active_program = prop_program +} + +def use_unlit_program() { + glUseProgram(unlit_program) + active_program = unlit_program +} + +def use_puff_program() { + glUseProgram(puff_program) + active_program = puff_program +} + +// `seed` only varies the edge lumpiness and spin between puffs. +def draw_puff(pos : float3; radius : float; color : float3; alpha, emissive, spin, seed : float) { + v_model = compose(pos, float4(0.0, 0.0, 0.0, 1.0), float3(1.0)) + f_Color = float4(color, alpha) + f_Material = float4(seed, radius, emissive, spin) + vs_puff_bind_uniform(active_program) + fs_puff_bind_uniform(active_program) +} + +// Material presets, so call sites name a surface instead of tuning four floats. +let MAT_MATTE = float4(0.85, 0.05, 0.0, 0.12) +let MAT_HULL = float4(0.34, 0.65, 0.0, 0.35) +let MAT_METAL = float4(0.24, 1.15, 0.0, 0.45) +let MAT_FOLIAGE = float4(0.92, 0.03, 0.0, 0.22) +let MAT_STONE = float4(0.78, 0.10, 0.0, 0.15) +let MAT_GLOW = float4(0.5, 0.4, 0.85, 0.6) + +// Reflectivity presets for the draw_prop `reflect_amt` argument. +let REFLECT_NONE = float2(0.0, 0.25) +let REFLECT_PAINT = float2(0.22, 0.10) +let REFLECT_METAL = float2(0.62, 0.30) +let REFLECT_CHROME = float2(0.90, 0.55) + +// `reflect_amt` is (reflectivity, head-on bias): x is how much environment the +// surface mixes in at all, y how much of that survives when looking straight at +// it. Chrome wants a high bias, painted metal a low one. +def draw_prop(pos, scale, color : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0); + material : float4 = MAT_MATTE; reflect_amt : float2 = float2(0.0, 0.25)) { + v_model = compose(pos, rot_quat, scale) + f_Color = float4(color, 1.0) + f_Material = material + f_Material2 = float4(reflect_amt.x, reflect_amt.y, 0.0, 0.0) + vs_prop_bind_uniform(active_program) + fs_prop_bind_uniform(active_program) +} + +def draw_unlit(pos, scale, color : float3; alpha : float = 1.0; + rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0); emissive : float = 0.0) { + v_model = compose(pos, rot_quat, scale) + f_Color = float4(color, alpha) + f_Material = float4(1.0, 0.0, emissive, 0.0) + vs_unlit_bind_uniform(active_program) + fs_unlit_bind_uniform(active_program) +} + +def draw_shadow_caster(pos, scale : float3; rot_quat : float4 = float4(0.0, 0.0, 0.0, 1.0)) { + v_model = compose(pos, rot_quat, scale) + vs_shadow_bind_uniform(active_program) +} From 919af4949fd5d2220e02ac134df3ab3d008e292c Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 09:48:25 -0700 Subject: [PATCH 12/17] river_run: package name must match the directory, or CI cannot find the card daspkg names the released card from package_name, so "river-run" emitted web/output64/examples/river-run/river-run.wasm while the workflow's verify step, its staging loop and the site card id all say river_run. The other three games hold dir == package_name == card id; this one now does too. Caught by building the card with emscripten locally rather than letting CI find it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/.das_package | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/games/river_run/.das_package b/examples/games/river_run/.das_package index 6a327bc382..46c77f9637 100644 --- a/examples/games/river_run/.das_package +++ b/examples/games/river_run/.das_package @@ -4,7 +4,7 @@ require daslib/daspkg [export] def package() { - package_name("river-run") + package_name("river_run") package_description("River Run: shadow-mapped river shooter on dasGlfw + dasOpenGL + dasAudio") } From 53ac3f64a99878e314a41fe8cb0fc7d159227d82 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 09:49:06 -0700 Subject: [PATCH 13/17] web: ignore the wasm64 build and output trees the pages workflow creates output/ and build/ cover the wasm32 playground dirs, but the same workflow also configures web/build64 and emits web/output64 for the example cards. Reproducing the pages build locally therefore left two untracked trees and a red preflight untracked gate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- web/.gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/.gitignore b/web/.gitignore index 2fe124cd72..c733287827 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,7 +1,9 @@ -# Build output +# Build output — wasm32 playground, then the wasm64 example cards output/ +output64/ # CMake build trees (in-source or cmake_temp/) +build64/ CMakeFiles/ CMakeCache.txt CopyOfCMakeCache.txt From d09faa25208a365d12d33ba5a36389af162e446d Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 10:24:09 -0700 Subject: [PATCH 14/17] examples/games: gate the card wiring, which nothing checked until a deploy broke A game shipped as a web card is written down in eight places, all by hand: the game directory, its package_name, the two deploy loops and the artifact-verify step, the examples-page card and its poster, the playground sample slug and its file list, and the interpreted fallback's copy of that same file list. The workflow that consumes them only runs on push to master, so any mismatch merges green and surfaces as a broken site. examples/games/REVIEW.das cross-checks all of it and runs per-PR in extended_checks. Each of its eight findings was verified by mutating the tree, running the gate, and restoring. The examples/ install block now excludes rule documents. They are the first ones under examples/, and examples/ installs wholesale, so without the exclusion the terminal install-manifest check would fatal on them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- .github/workflows/extended_checks.yml | 10 ++ CMakeLists.txt | 6 + examples/games/REVIEW.das | 244 ++++++++++++++++++++++++++ examples/games/REVIEW.md | 9 + 4 files changed, 269 insertions(+) create mode 100644 examples/games/REVIEW.das create mode 100644 examples/games/REVIEW.md diff --git a/.github/workflows/extended_checks.yml b/.github/workflows/extended_checks.yml index baaa06dea2..bf33fd6edd 100644 --- a/.github/workflows/extended_checks.yml +++ b/.github/workflows/extended_checks.yml @@ -282,6 +282,16 @@ jobs: - name: "Run tutorial dry-runs" run: cmake --build ./build --config Release --target dry_run_tutorials + - name: "Check the example games stay wired to the site" + # examples/games/REVIEW.das cross-checks the card id every place it is written + # down - the game directory, its package_name, the deploy loops and the verify + # step, the examples-page card and its poster, the playground sample and the + # interpreted fallback's file bundle. All of that is hand-maintained, and the + # deploy that consumes it only runs on master, so a mismatch is invisible until + # the site is already broken. Linux only: it reads files, nothing platform-bound. + if: matrix.target == 'linux' + run: $BIN/daslang examples/games/REVIEW.das + - name: "Verify authored-doc code blocks (nightly only)" # doc-verify compiles every das code block of the authored RST corpus # (skills/internal/doc_sweep.md). Nightly-only per policy: doc rot is not a diff --git a/CMakeLists.txt b/CMakeLists.txt index f0162b6180..71aaa98f88 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2168,6 +2168,12 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/examples/ PATTERN "daspkg.lock" EXCLUDE PATTERN ".daspkg.log" EXCLUDE PATTERN "*.shared_module" EXCLUDE + # rule documents are repo-internal and never install; the terminal manifest + # check below fatals on any that slip through + PATTERN "REVIEW.md" EXCLUDE + PATTERN "REVIEW.das" EXCLUDE + PATTERN "ARCHITECTURE.md" EXCLUDE + PATTERN "LAWS.md" EXCLUDE # soundfonts are fetched locally and carry unclear redistribution terms — # they must never ride into a release bundle PATTERN "*.sf2" EXCLUDE diff --git a/examples/games/REVIEW.das b/examples/games/REVIEW.das new file mode 100644 index 0000000000..6a995649db --- /dev/null +++ b/examples/games/REVIEW.das @@ -0,0 +1,244 @@ +options gen2 + +require strings +require daslib/strings_boost +require daslib/fio +require dastest/review_gate + +// The mechanical half of examples/games/REVIEW.md (contract: REVIEW_COMMON.md at the repo root). +// Run from the repo root: bin/daslang examples/games/REVIEW.das - exit 0 clean, 1 with findings. + +let PAGES_YML = ".github/workflows/pages.yml" +let EXAMPLES_JS = "site/files/examples.js" +let SAMPLES_JSON = "web/examples/ui/samples/data.json" +let INTERP_HTML = "site/examples/_interp.html" +let PORTS_DIR = "web/examples/ui/samples/examples" +let GAMES_DIR = "examples/games" + +// What the interpreted fallback mounts for a game its GAME_FILES map does not name. +let DEFAULT_BUNDLE = "live_stub.das main.das" + +def private dir_exists(path : string) : bool { + let st = stat(path) + return st.is_valid && st.is_dir +} + +// The text one line puts between two markers: `id: 'pacman'` -> "pacman". +def private between(line, head, tail : string) : string { + let from = find(line, head) + return "" if (from < 0) + let start = from + length(head) + let stop = find(line, tail, start) + return stop < 0 ? "" : slice(line, start, stop) +} + +// Every id a file spells between the same two markers, one line at a time. +def private ids_between(path, head, tail : string) : array { + var out : array + for (raw in split(fread(path), "\n")) { + let id = between(strip(raw), head, tail) + out |> push(id) if (!empty(id)) + } + return <- out +} + +// The deploy walks its games in `for g in ; do` loops - one builds the cards, one +// stages them into the site - so it carries the game list more than once. +def private workflow_card_lists(text : string) : array { + var out : array + for (raw in split(text, "\n")) { + let line = strip(raw) + continue if (!starts_with(line, "for g in ") || !ends_with(line, "; do")) + out |> push(strip(between(line, "for g in ", "; do"))) + } + return <- out +} + +// Cards the artifact-verify step demands a built wasm for. It also lists the showcase +// cards, which are not games, so this is a superset of the game list. +def private verified_card_ids(text : string) : array { + var out : array + let head = "web/output64/examples/" + for (raw in split(text, "\n")) { + var line = strip(raw) + continue if (!starts_with(line, head)) + line = slice(line, length(head), length(line)) + let sep = find(line, "/") + continue if (sep < 0) + out |> push(slice(line, 0, sep)) + } + return <- out +} + +// The file names one bracketed list holds, as base names, so a sample's +// "examples/river_run/main.das" and the fallback's "main.das" compare directly. +def private bundle_in_list(line : string; quote : int) : array { + var out : array + let open = find(line, "[") + return <- out if (open < 0) + var start = -1 + peek_data(line) $(d) { + for (i in range(open, length(d))) { + let b = int(d[i]) + break if (b == ']') + continue if (b != quote) + if (start < 0) { + start = i + 1 + } else { + out |> push(base_name(slice(d, start, i))) + start = -1 + } + } + } + out |> sort + return <- out +} + +// Each sample's file bundle, by slug. The slug line precedes its files line. +def private sample_bundles() : table> { + var out : table> + var slug = "" + for (raw in split(fread(SAMPLES_JSON), "\n")) { + let line = strip(raw) + let s = between(line, "\"slug\" : \"", "\"") + if (!empty(s)) { + slug = s + continue + } + continue if (empty(slug) || !starts_with(line, "\"files\"")) + out[slug] <- bundle_in_list(line, '"') + slug = "" + } + return <- out +} + +// The interpreted fallback's GAME_FILES map. Read only between its braces - single +// quotes and brackets are ordinary JavaScript everywhere else in the page. +def private interp_bundles() : table> { + var out : table> + var inside = false + for (raw in split(fread(INTERP_HTML), "\n")) { + let line = strip(raw) + if (!inside) { + inside = find(line, "GAME_FILES = \{") >= 0 + continue + } + break if (starts_with(line, "\}")) + let id = between(line, "'", "'") + continue if (empty(id)) + out[id] <- bundle_in_list(line, '\'') + } + return <- out +} + +// A ported game's bundle is written down twice - once as the sample's file list, once +// as the interpreted fallback's - and a file added to the port reaches neither on its +// own. Both copies name one bundle, and every file in it is there to serve. +def private check_port_bundles(games : array) { + let samples <- sample_bundles() + let interp <- interp_bundles() + for (g in games) { + continue if (!dir_exists("{PORTS_DIR}/{g}")) + get(samples, g) $(files) { + var mounted = DEFAULT_BUNDLE + get(interp, g) $(named) { + mounted = join(named, " ") + } + let listed = join(files, " ") + if (listed != mounted) { + gate_finding(INTERP_HTML, find_line(fread(INTERP_HTML), "GAME_FILES = \{"), + "the interpreted fallback mounts \"{mounted}\" for {g} while its sample lists \"{listed}\" - the two name one bundle") + } + for (f in files) { + continue if (fexist("{PORTS_DIR}/{g}/{f}")) + gate_finding(SAMPLES_JSON, "{g} lists {f}, which is not in {PORTS_DIR}/{g} - the sample would fail to load it") + } + } + } +} + +// daspkg names the released card from package_name and emits /.wasm, while +// the deploy verifies and stages /.wasm. The two spellings have to be one. +def private check_package_names { + dir(GAMES_DIR) $(name) { + return if (name == "." || name == "..") + let pkg = "{GAMES_DIR}/{name}/.das_package" + return if (!fexist(pkg)) + let text = fread(pkg) + return if (find(text, "package_name(\"{name}\")") >= 0) + gate_finding(pkg, find_line(text, "package_name("), + "package_name must be \"{name}\", to match the directory - daspkg names the released card from it, so any other spelling emits an artifact the deploy never looks for") + } +} + +// Each card's poster, by card id. The id line opens a card, its poster line follows. +def private card_posters() : table { + var out : table + var id = "" + for (raw in split(fread(EXAMPLES_JS), "\n")) { + let line = strip(raw) + let found = between(line, "id: '", "'") + if (!empty(found)) { + id = found + continue + } + continue if (empty(id) || !starts_with(line, "poster:")) + out[id] = between(line, "'", "'") + id = "" + } + return <- out +} + +// A game the deploy builds needs its sources, an entry in every other place that names +// it, and - when a playground port exists - a sample slug, or the port ships unlisted. +def private check_card_wiring(pages : string; games : array) { + let verified <- verified_card_ids(pages) + let carded <- ids_between(EXAMPLES_JS, "id: '", "'") + let slugs <- ids_between(SAMPLES_JSON, "\"slug\" : \"", "\"") + let posters <- card_posters() + for (g in games) { + if (!dir_exists("{GAMES_DIR}/{g}")) { + gate_finding(PAGES_YML, find_line(pages, g), + "the deploy builds a card for {g}, but there is no {GAMES_DIR}/{g}") + continue + } + if (find_index(verified, g) < 0) { + gate_finding(PAGES_YML, "{g} is built but the artifact-verify step never demands web/output64/examples/{g}/{g}.wasm - a card that failed to build would deploy as a placeholder without reddening the run") + } + if (find_index(carded, g) < 0) { + gate_finding(EXAMPLES_JS, "{g} is deployed but the examples page has no card for it - add an entry with id: '{g}'") + } + get(posters, g) $(poster) { + gate_finding(EXAMPLES_JS, "the {g} card names poster {poster}, which is not at site/{poster} - the card would show a broken image") if (!fexist("site/{poster}")) + } + if (dir_exists("{PORTS_DIR}/{g}") && find_index(slugs, g) < 0) { + gate_finding(SAMPLES_JSON, "{PORTS_DIR}/{g} holds a playground port that no sample lists - add one with slug \"{g}\"") + } + } +} + +[export] +def main() : int { + if (!fexist(PAGES_YML)) { + to_log(LOG_ERROR, "examples/games/REVIEW.das: run from the repo root\n") + return 2 + } + let pages = fread(PAGES_YML) + let lists <- workflow_card_lists(pages) + if (empty(lists)) { + gate_finding(PAGES_YML, "no `for g in ; do` loop - this gate reads the deployed game list from those loops and can no longer see one") + } else { + // A game added to one loop and not the other builds a card the deploy never + // copies into the site, or stages a card it never built. + for (i in range(1, length(lists))) { + continue if (lists[i] == lists[0]) + gate_finding(PAGES_YML, find_line(pages, "for g in {lists[i]}; do"), + "this loop walks \"{lists[i]}\" while another walks \"{lists[0]}\" - every `for g in` loop in this workflow names the same games") + } + let games <- [for (g in split(lists[0], " ")); g; where !empty(g)] + check_card_wiring(pages, games) + check_port_bundles(games) + } + check_package_names() + return gate_verdict("examples/games") +} diff --git a/examples/games/REVIEW.md b/examples/games/REVIEW.md new file mode 100644 index 0000000000..87c6631de1 --- /dev/null +++ b/examples/games/REVIEW.md @@ -0,0 +1,9 @@ +# examples/games Code Review Checklist + +**Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** + +**Weakening the card-wiring check in `REVIEW.das` (beside this file) is a defect.** + +**A diff that adds a step to the deploy workflow naming the games it deploys writes that +list as a `for g in ; do` loop.** The check beside this file reads the deployed game +list from those loops, so a list spelled any other way is one nothing cross-checks. From a85f0b94529e21a46ee69a981585ea36370f9298 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 10:37:40 -0700 Subject: [PATCH 15/17] dasGlsl: one winding rule for all three GenDirections, with a test The earlier commit fixed gen_cylinder's and gen_cone's index order, which was right for GenDirection.xy and yz and wrong for xz. GenDirection.xz maps the generated xy shape onto xz by swapping two axes, which is a mirror: determinant negative, so it reverses the orientation of every triangle it moves. No single index order can be correct for all three directions. The transform now reports its handedness and the four direction-taking generators reverse the winding back when it mirrored, so all three directions come out counter-clockwise seen from outside without a vertex or a UV moving. That also repairs gen_plane(xz) and gen_prism(xz), which have been inverted on master all along - gen_plane(xz) is what tank_game and arcanoid draw their ground with. tests/glsl/test_geom_gen_winding.das recomputes each triangle's winding from its vertices and compares it against that triangle's own normals, so it survives a retessellation. It is red on master's generator (7 of 15) and on the previous commit's (4 of 15). River Run's geo_plane_xz was built and never drawn; dropped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- examples/games/river_run/main.das | 1 - examples/games/river_run/rr_globals.das | 1 - modules/dasGlsl/glsl/geom_gen.das | 27 +++++++- tests/glsl/test_geom_gen_winding.das | 69 +++++++++++++++++++ .../ui/samples/examples/river_run/main.das | 1 - .../samples/examples/river_run/rr_globals.das | 1 - 6 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/glsl/test_geom_gen_winding.das diff --git a/examples/games/river_run/main.das b/examples/games/river_run/main.das index 913ed2ddb6..c763e4eace 100644 --- a/examples/games/river_run/main.das +++ b/examples/games/river_run/main.das @@ -19,7 +19,6 @@ def create_gl_objects() { geo_sphere <- create_geometry_fragment <| gen_sphere(14, 10, false) geo_cube <- create_geometry_fragment <| gen_cube() - geo_plane_xz <- create_geometry_fragment <| gen_plane(GenDirection.xz) geo_cylinder <- create_geometry_fragment <| gen_cylinder(GenDirection.xy, 12) geo_cone <- create_geometry_fragment <| gen_cone(GenDirection.xy, 12) geo_prism <- create_geometry_fragment <| gen_prism(GenDirection.xy) diff --git a/examples/games/river_run/rr_globals.das b/examples/games/river_run/rr_globals.das index 1ddbf50886..20f584ce9a 100644 --- a/examples/games/river_run/rr_globals.das +++ b/examples/games/river_run/rr_globals.das @@ -570,7 +570,6 @@ def begin_env_transition(idx : int) { var geo_sphere : OpenGLGeometryFragment var geo_cube : OpenGLGeometryFragment -var geo_plane_xz : OpenGLGeometryFragment var geo_cylinder : OpenGLGeometryFragment var geo_cone : OpenGLGeometryFragment var geo_prism : OpenGLGeometryFragment diff --git a/modules/dasGlsl/glsl/geom_gen.das b/modules/dasGlsl/glsl/geom_gen.das index 329671ef2f..80d9827979 100644 --- a/modules/dasGlsl/glsl/geom_gen.das +++ b/modules/dasGlsl/glsl/geom_gen.das @@ -115,7 +115,7 @@ enum GenDirection { yz } -def private apply_gen_direction_tm(plt : GenDirection; var frag : GeometryFragment) { +def private gen_direction_tm(plt : GenDirection) : float3x3 { var tm : float3x3 identity(tm) if (plt == GenDirection.xy) { @@ -126,12 +126,33 @@ def private apply_gen_direction_tm(plt : GenDirection; var frag : GeometryFragme swap(tm[0], tm[2]) // todo: verify tm[2][0] = -1. } + return tm +} + +def private apply_gen_direction_tm(plt : GenDirection; var frag : GeometryFragment) { + let tm = gen_direction_tm(plt) for (vtx in frag.vertices) { vtx.xyz = tm * vtx.xyz vtx.normal = normalize(tm * vtx.normal) } } +// xz maps the generated xy shape onto xz by swapping two axes, which is a mirror: +// its determinant is negative, so it reverses the orientation of every triangle it +// moves. Reversing the index order back leaves all three directions wound the same +// way - counter-clockwise seen from outside, agreeing with the per-vertex normals - +// without moving a vertex or a UV. Call it once indices are complete. +def private unmirror_winding(plt : GenDirection; var frag : GeometryFragment) { + let tm = gen_direction_tm(plt) + return if (dot(tm[0], cross(tm[1], tm[2])) > 0.0) + for (t in range(length(frag.indices) / 3)) { + let i = t * 3 + 1 + let swapped = frag.indices[i] + frag.indices[i] = frag.indices[i + 1] + frag.indices[i + 1] = swapped + } +} + def gen_plane(plt : GenDirection) { var frag : GeometryFragment frag.vertices <- [GeometryPreviewVertex( @@ -143,6 +164,7 @@ def gen_plane(plt : GenDirection) { apply_gen_direction_tm(plt, frag) frag.indices <- array(0, 1, 2, 2, 3, 0) frag.prim = GeometryFragmentType.triangles + unmirror_winding(plt, frag) gen_bbox(frag) return <- frag } @@ -221,6 +243,7 @@ def gen_cylinder(plt : GenDirection; sectorCount : int) { } delete unitVertices frag.prim = GeometryFragmentType.triangles + unmirror_winding(plt, frag) gen_bbox(frag) return <- frag } @@ -289,6 +312,7 @@ def gen_cone(plt : GenDirection; sectorCount : int) { apply_gen_direction_tm(plt, frag) delete unitVertices frag.prim = GeometryFragmentType.triangles + unmirror_winding(plt, frag) gen_bbox(frag) return <- frag } @@ -341,6 +365,7 @@ def gen_prism(plt : GenDirection) { apply_gen_direction_tm(plt, frag) frag.prim = GeometryFragmentType.triangles + unmirror_winding(plt, frag) gen_bbox(frag) return <- frag } diff --git a/tests/glsl/test_geom_gen_winding.das b/tests/glsl/test_geom_gen_winding.das new file mode 100644 index 0000000000..10cfb94255 --- /dev/null +++ b/tests/glsl/test_geom_gen_winding.das @@ -0,0 +1,69 @@ +options gen2 +options indenting = 4 + +// Regression gate for triangle winding in the geometry generators. +// +// Every generator sets an outward per-vertex normal, and callers draw with +// GL_CULL_FACE / GL_BACK under the default CCW front face. So each triangle must be +// wound counter-clockwise seen from outside -- its edge cross product has to point the +// same way as its own vertex normals. A triangle that disagrees is drawn inside out: +// the lit outer surface is culled and the unlit interior shows through. +// +// Nothing checked this, and two separate defects had accumulated. gen_cylinder and +// gen_cone pushed indices in the wrong order. Independently, GenDirection.xz maps the +// generated xy shape onto xz by swapping two axes -- a mirror, determinant negative -- +// which reverses the orientation of every triangle it moves, so no single index order +// could be right for all three directions at once. +// +// The audit is geometric, not a golden index list: it recomputes the winding from the +// vertices, so it keeps holding if a generator changes its tessellation. + +require dastest/testing_boost public +require geometry/geom_gen +require math + +// Triangles whose winding disagrees with their own vertex normals. +def private inside_out_count(frag : GeometryFragment) : int { + var bad = 0 + for (t in range(length(frag.indices) / 3)) { + let a = frag.vertices[frag.indices[t * 3]] + let b = frag.vertices[frag.indices[t * 3 + 1]] + let c = frag.vertices[frag.indices[t * 3 + 2]] + let geo = cross(b.xyz - a.xyz, c.xyz - a.xyz) + let outward = a.normal + b.normal + c.normal + // A degenerate triangle or a cancelling normal sum carries no orientation. + continue if (length(geo) < 0.000001 || length(outward) < 0.000001) + bad++ if (dot(normalize(geo), normalize(outward)) <= 0.0) + } + return bad +} + +[test] +def test_geom_gen_winding(t : T?) { + for (d in [GenDirection.xy, GenDirection.xz, GenDirection.yz]) { + t |> run("gen_cylinder {d} is wound counter-clockwise from outside") <| @(t : T?) { + let frag <- gen_cylinder(d, 12) + t |> success(inside_out_count(frag) == 0, "no inside-out triangles") + } + t |> run("gen_cone {d} is wound counter-clockwise from outside") <| @(t : T?) { + let frag <- gen_cone(d, 12) + t |> success(inside_out_count(frag) == 0, "no inside-out triangles") + } + t |> run("gen_plane {d} is wound counter-clockwise from outside") <| @(t : T?) { + let frag <- gen_plane(d) + t |> success(inside_out_count(frag) == 0, "no inside-out triangles") + } + t |> run("gen_prism {d} is wound counter-clockwise from outside") <| @(t : T?) { + let frag <- gen_prism(d) + t |> success(inside_out_count(frag) == 0, "no inside-out triangles") + } + } + t |> run("gen_sphere is wound counter-clockwise from outside") <| @(t : T?) { + let frag <- gen_sphere(16, 8, false) + t |> success(inside_out_count(frag) == 0, "no inside-out triangles") + } + t |> run("gen_cube is wound counter-clockwise from outside") <| @(t : T?) { + let frag <- gen_cube() + t |> success(inside_out_count(frag) == 0, "no inside-out triangles") + } +} diff --git a/web/examples/ui/samples/examples/river_run/main.das b/web/examples/ui/samples/examples/river_run/main.das index cd4da88e15..3cb5137eac 100644 --- a/web/examples/ui/samples/examples/river_run/main.das +++ b/web/examples/ui/samples/examples/river_run/main.das @@ -21,7 +21,6 @@ def create_gl_objects() { geo_sphere <- create_geometry_fragment <| gen_sphere(14, 10, false) geo_cube <- create_geometry_fragment <| gen_cube() - geo_plane_xz <- create_geometry_fragment <| gen_plane(GenDirection.xz) geo_cylinder <- create_geometry_fragment <| gen_cylinder(GenDirection.xy, 12) geo_cone <- create_geometry_fragment <| gen_cone(GenDirection.xy, 12) geo_prism <- create_geometry_fragment <| gen_prism(GenDirection.xy) diff --git a/web/examples/ui/samples/examples/river_run/rr_globals.das b/web/examples/ui/samples/examples/river_run/rr_globals.das index 8332c01609..202f04c1a5 100644 --- a/web/examples/ui/samples/examples/river_run/rr_globals.das +++ b/web/examples/ui/samples/examples/river_run/rr_globals.das @@ -560,7 +560,6 @@ def begin_env_transition(idx : int) { var geo_sphere : OpenGLGeometryFragment var geo_cube : OpenGLGeometryFragment -var geo_plane_xz : OpenGLGeometryFragment var geo_cylinder : OpenGLGeometryFragment var geo_cone : OpenGLGeometryFragment var geo_prism : OpenGLGeometryFragment From 97cb94abd687c3618e46e73e829ccb37c587719e Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 10:47:11 -0700 Subject: [PATCH 16/17] review: pin the GLSL for-header, drive the port from eval_main_loop Two Copilot findings, both accepted. The `continue` codegen fix had no test, and its regression mode is a GPU hang rather than a red lane. tests/glsl/test_for_continue_emission.das emits a shader whose range- and dim-for loops both carry a `continue`, then asserts the increment appears in a for-header and that no `while` survives - the property, not the emitter's choice of temporary names. Red on master's emitter, 3 of 5. The playground port drove itself with a blocking `while`, copied from the desktop entry point. All three other ported games use eval_main_loop, which lowers to requestAnimationFrame under emscripten; a blocking loop never gives the browser its event loop back. It also collected the GC a second time per frame, which live_end_frame already does. The desktop entry point keeps its blocking loop - that is what the other three do too, and the compiled card runs its main on a worker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- tests/glsl/test_for_continue_emission.das | 61 +++++++++++++++++++ .../ui/samples/examples/river_run/main.das | 13 ++-- 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 tests/glsl/test_for_continue_emission.das diff --git a/tests/glsl/test_for_continue_emission.das b/tests/glsl/test_for_continue_emission.das new file mode 100644 index 0000000000..e7a465c5bf --- /dev/null +++ b/tests/glsl/test_for_continue_emission.das @@ -0,0 +1,61 @@ +options gen2 +options indenting = 4 + +// Regression gate for `continue` inside a lowered for-loop. +// +// The emitter used to lower a range- or dim-for to `while (cond) { body; i++ }`, with the +// increment as the LAST STATEMENT OF THE BODY. A `continue` in that body jumps over the +// increment, so the loop variable never advances and the shader spins forever -- a GPU hang +// with no diagnostic in any tier, because the das source is correct and the GLSL compiles. +// +// The loops now emit a real `for ( ; cond; i++ )` header, so `continue` reaches the +// increment. The asserts pin the property, not the spelling: the increment appears inside a +// for-header, and no `while` survives. Renaming the emitter's temporaries keeps them green. + +require dastest/testing_boost public +require glsl/glsl_opengl +require math +require strings + +var @in @location a_uv : float2 +var @out o_color : float4 + +[fragment_program(name="loop_continue_glsl")] +def loop_continue_shader { + var acc = 0.0 + for (i in range(4)) { + if (i == 2) { + continue + } + acc += float(i) * a_uv.x + } + var d : float[3] + d[0] = 1.0 + d[1] = 2.0 + d[2] = 3.0 + for (v in d) { + if (v > 2.5) { + continue + } + acc += v + } + o_color = float4(acc) +} + +def private has(hay, needle : string) : bool => find(hay, needle) >= 0 + +[test] +def test_for_continue_emission(t : T?) { + t |> run("the shader under test really does emit a continue") <| @(t : T?) { + t |> success(has(loop_continue_glsl, "continue;"), "continue; present in the emitted GLSL") + } + t |> run("a range for-loop carries its increment in the for header") <| @(t : T?) { + t |> success(has(loop_continue_glsl, "; i++ )"), "for ( ; ... ; i++ ) header emitted") + } + t |> run("a dim for-loop carries its increment in the for header") <| @(t : T?) { + t |> success(has(loop_continue_glsl, "; v++ )"), "for ( ; ... ; v++ ) header emitted") + } + t |> run("no loop is lowered to a while with a body increment") <| @(t : T?) { + t |> success(!has(loop_continue_glsl, "while ("), "no while ( in the emitted GLSL") + } +} diff --git a/web/examples/ui/samples/examples/river_run/main.das b/web/examples/ui/samples/examples/river_run/main.das index 3cb5137eac..4dffaa90f9 100644 --- a/web/examples/ui/samples/examples/river_run/main.das +++ b/web/examples/ui/samples/examples/river_run/main.das @@ -374,18 +374,21 @@ def parse_args() { } } +// Entry point for the standalone cross-compiled build (daslang -exe -> .wasm). +// eval_main_loop drives the block once per frame: a blocking while-loop natively, +// emscripten_set_main_loop (rAF) on the web -- so the same main runs in the browser +// without blocking. Returns false to end the loop. update() ends its frame through +// live_end_frame, which collects, so the loop body must not collect again. [export] def main() { parse_args() init() var frame_count = 0 - while (!exit_requested()) { + eval_main_loop() { update() - maybe_collect_gc() frame_count++ - if (max_frame_limit > 0 && frame_count >= max_frame_limit) { - break - } + return false if (max_frame_limit > 0 && frame_count >= max_frame_limit) + return !exit_requested() } shutdown() } From dd1dfe4acb30184ee6b78994f7f1164a2ee1d6cd Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 27 Aug 2026 11:18:48 -0700 Subject: [PATCH 17/17] tests: drop the unused require math from the for-continue emission test STYLE030 on the extended_checks lint lane. The local pre-push lint gate did not see it: that gate lints git diff origin/master...HEAD, so a file still untracked when it runs is invisible - it has to be committed first. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Nnf64QnUkgmffRqg58BRXk --- tests/glsl/test_for_continue_emission.das | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/glsl/test_for_continue_emission.das b/tests/glsl/test_for_continue_emission.das index e7a465c5bf..a4c341090a 100644 --- a/tests/glsl/test_for_continue_emission.das +++ b/tests/glsl/test_for_continue_emission.das @@ -14,7 +14,6 @@ options indenting = 4 require dastest/testing_boost public require glsl/glsl_opengl -require math require strings var @in @location a_uv : float2