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" } 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/.github/workflows/pages.yml b/.github/workflows/pages.yml index 43998521d3..1d6f69304a 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 @@ -487,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/" 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. diff --git a/examples/games/river_run/.das_package b/examples/games/river_run/.das_package index 83ebeaf6fe..46c77f9637 100644 --- a/examples/games/river_run/.das_package +++ b/examples/games/river_run/.das_package @@ -4,11 +4,13 @@ require daslib/daspkg [export] def package() { - package_name("river-run") - package_description("River Run example with audio + HUD; ships on dasGlfw + dasOpenGL + dasAudio") + package_name("river_run") + 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/gameplay.das b/examples/games/river_run/gameplay.das index 6f8d1f13e8..4b4370d91e 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(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(PARTICLE_LIFETIME * 0.65, PARTICLE_LIFETIME * 1.25) + p.lifetime = random_range(0.45, 1.15) p.max_life = p.lifetime - p.size = random_range(0.07, 0.26) * (0.7 + r * 0.55) + 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( @@ -161,23 +282,79 @@ 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) { apply_decs_template(cmp, RiverTree( - pos = pos, + pos = grounded, size = size, 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 )) } } 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 @@ -268,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() } @@ -471,13 +657,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,15 +759,22 @@ 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) + 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) { @@ -644,7 +831,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 +844,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 @@ -828,10 +1013,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) } @@ -839,6 +1038,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() { @@ -1096,13 +1302,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) { @@ -1268,121 +1473,157 @@ 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() { + let per_kind = max(length(geo_flora) / FLORA_VARIANTS, 1) 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) + 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) { - // 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 +1633,523 @@ 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, REFLECT_PAINT) 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, REFLECT_PAINT) 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, 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() } } -// Pine trees: trunk + stacked cones +// 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.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) - 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) - 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) - 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: 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 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.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) + 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() } } -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) +// 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() + } - // Explosion chunks: transparent and spinning + 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) { - 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 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) - geo_cube |> draw_geometry_fragment() + 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() - // Trail particles (blend based on remaining life) + // 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 * 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) - 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() } - // Player bullets: thin cyan cylinder elongated along velocity - glUseProgram(phong_program) - active_program = phong_program + 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_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) + // 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) - 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) } +// 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_shadows(game_time) render_player(game_time) - render_enemies() + render_enemies(game_time) render_obstacles(game_time) - render_projectiles() + render_debris() +} + +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..b89a90a4ae 100644 --- a/examples/games/river_run/hud.das +++ b/examples/games/river_run/hud.das @@ -1,65 +1,191 @@ 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) +} + +// 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() { - 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_pause_banner() } 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..c763e4eace 100644 --- a/examples/games/river_run/main.das +++ b/examples/games/river_run/main.das @@ -8,23 +8,45 @@ 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() + 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") @@ -32,7 +54,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 +78,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 --- @@ -106,32 +227,103 @@ def init() { current_section = 0 cam_x = 0.0 init_river() - rebuild_river_geometry() - river_dirty = false + } 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 } -[export] -def update() { - if (!live_begin_frame()) { +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 + } + } +} - 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() - // 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) + 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(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 +343,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()) + 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) - - // 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..4b18cb9f08 100644 --- a/examples/games/river_run/river.das +++ b/examples/games/river_run/river.das @@ -237,18 +237,145 @@ 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) +} + +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 + } -// Terrain extends this far outward from each bank -let TERRAIN_EXTEND = 90.0 + 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 --- -// 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) { +// 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 = 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)) + 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) @@ -257,59 +384,128 @@ 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)) + 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) { - 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 zero_n = float3(0.0, 0.0, 1.0) - let zero_uv = float2(0.0, 0.0) + 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(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) } } @@ -317,40 +513,87 @@ def gen_river_banks() : GeometryFragment { 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) { - 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, 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 { - 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, s1, + s0.left_bank_x, s0.right_bank_x, s1.left_bank_x, s1.right_bank_x, z) } } @@ -368,23 +611,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..dfe3269723 100644 --- a/examples/games/river_run/rr_audio.das +++ b/examples/games/river_run/rr_audio.das @@ -19,12 +19,24 @@ 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 +// 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 @@ -253,6 +265,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() { @@ -276,53 +289,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) } @@ -332,41 +378,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) ────────── @@ -377,7 +434,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) } @@ -385,6 +443,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) } @@ -392,6 +451,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) } @@ -400,6 +460,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). @@ -431,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) @@ -477,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 486a614dc7..20f584ce9a 100644 --- a/examples/games/river_run/rr_globals.das +++ b/examples/games/river_run/rr_globals.das @@ -7,10 +7,20 @@ 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 +// 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 +// 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 +114,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 @@ -206,6 +219,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 @@ -214,6 +232,9 @@ struct RiverTree { green_tint : float green_shift : float trunk_ratio : float + kind : int + seed : int + yaw : float } [decs_template] @@ -231,6 +252,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 @@ -241,6 +275,9 @@ struct Particle { size : float spin_speed : float spin_phase : float + kind : ParticleKind + drag : float + gravity : float } // --- River Segment --- @@ -286,6 +323,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 @@ -304,6 +342,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 @@ -311,196 +354,232 @@ 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() +} -var flat_program : uint -var phong_program : uint -var river_program : uint -var rotor_program : uint -var grass_program : uint -var active_program : uint +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 @@ -521,27 +600,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 +627,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..cbaa5aaa9e --- /dev/null +++ b/examples/games/river_run/rr_live.das @@ -0,0 +1,228 @@ +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 + // 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 + 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, + 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++; } + 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) +} + +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 + 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..1830bd3b3a --- /dev/null +++ b/examples/games/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/examples/games/river_run/rr_postfx.das b/examples/games/river_run/rr_postfx.das new file mode 100644 index 0000000000..cc2a41eac0 --- /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 + +// 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/examples/games/river_run/rr_shaders.das b/examples/games/river_run/rr_shaders.das new file mode 100644 index 0000000000..7634ad3bb6 --- /dev/null +++ b/examples/games/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) +} diff --git a/modules/dasGlsl/glsl/geom_gen.das b/modules/dasGlsl/glsl/geom_gen.das index d66109adc0..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 } @@ -186,38 +208,42 @@ 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 + unmirror_winding(plt, frag) gen_bbox(frag) return <- frag } @@ -264,11 +290,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 @@ -282,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 } @@ -334,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/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 } 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 - +