diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c1c5c25..99901009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,25 +75,6 @@ jobs: - name: sokol backend unit tests + audio.zig compile-check run: cd backends/sokol && zig build test - - name: wgpu backend WAV parser tests - # Re-enabled: wgpu_native_zig + zglfw are now on 0.16-compatible - # pins, and `zig build test` builds only the pure WAV parser - # (host target, no native deps), so it stays cheap on both OSes. - run: cd backends/wgpu && zig build test - - # Build the standalone wgpu backend demo on macOS. This is the - # regression lock for the Apple render path: the example used to - # link a nonexistent Dawn `zdawn` artifact (panicked "unable to - # find artifact 'zdawn'"), and surface creation was Win32-only. - # Building here exercises example/build.zig (drops Dawn, links the - # Metal/QuartzCore/Foundation frameworks wgpu-native needs) AND - # compiles window.zig's CAMetalLayer surface path for real on an - # Apple target. macOS-only: the frameworks + Cocoa surface only - # exist here, and it's the platform the break shipped on. - - name: wgpu backend demo build (macOS) - if: runner.os == 'macOS' - run: cd backends/wgpu/example && zig build - # Linux-only: raylib-zig transitively pulls hexops/xcode-frameworks # via `git+https://`, which Zig's git fetcher can't resolve on the # macos-latest runner ("unable to discover remote git server @@ -249,39 +230,6 @@ jobs: cd .labelle/raylib_desktop zig build - # wgpu desktop example — build-only, same recipe. This backend - # previously had NO example build in CI at all, which let a codegen - # bug ship (its lifecycle template lacked the {{module_vars}} slot, - # so generated mains referenced undeclared `_preview_*` helpers and - # failed sema — caught only when a real project switched backends). - # bgfx isn't built here (or anywhere in this repo) — it's extracted to - # labelle-bgfx, which builds + tests it (incl. the android .so) in its - # own CI. This job covers the bundled backends' example codegen. - - name: Generate + build the wgpu example - env: - LABELLE_ASSEMBLER: ${{ github.workspace }}/labelle-assembler/zig-out/bin/labelle-assembler - run: | - cd labelle-assembler/examples/wgpu - $GITHUB_WORKSPACE/labelle-cli/zig-out/bin/labelle generate - - # Lock the `link_wgpu` template: wgpu-native's static lib pulls - # Apple system frameworks, so the generated build MUST link - # Foundation/QuartzCore/Metal on macOS/iOS or those targets fail - # to link with undefined Metal symbols. This runner is Linux (so - # `zig build` below can't link-test the frameworks), but the - # generated build.zig is platform-agnostic — assert the framework - # links are present so a template regression is caught here even - # without a macOS runner. The standalone demo build in the - # build-and-test macOS job link-tests them for real. - for fw in Foundation QuartzCore Metal; do - grep -qF "linkFramework(\"$fw\"" .labelle/wgpu_desktop/build.zig \ - || { echo "E: generated wgpu build.zig is missing linkFramework(\"$fw\") — link_wgpu template regression"; exit 1; } - done - echo "PASS: generated wgpu build.zig links Foundation/QuartzCore/Metal" - - cd .labelle/wgpu_desktop - zig build - # External (out-of-tree) backend wiring, backend-AGNOSTIC. Proves the # opt-in `.backend_package` path end-to-end: `install` FETCHES the backend # package from GitHub into the cache, then `generate` stages it + codegens diff --git a/backends/null/backend.manifest.zon b/backends/null/backend.manifest.zon new file mode 100644 index 00000000..7e181378 --- /dev/null +++ b/backends/null/backend.manifest.zon @@ -0,0 +1,25 @@ +.{ + // null backend manifest — pluggable-backends epic (labelle-assembler#386). + // + // Presence of this file opts the null DESKTOP build into the manifest-splice + // codegen path (manifest_splice.zig) instead of the enum `switch (cfg.backend)` + // sections in src/templates/build_zig.txt. Spliced output is byte-identical to + // the enum path (verified against a generated baseline) — the step that lets + // null move out-of-tree (its build sections travel WITH the package). + // + // Loop-style (the generated headless main drives a fixed-frame tick loop). No + // params: pure-Zig, zero native deps, and the LINK fragment is EMPTY — null has + // no artifact to link (the enum path's `.null => {}` emitted nothing). + .dir_name = "null", + .dep_name = "labelle_null", + .loop_style = .loop, + .main_loop_template = "templates/headless.txt", + .build_fragments = .{ + .backend_dep = "build_fragments/backend_dep.txt", + .link = "build_fragments/link.txt", + }, + .params = .{ + .backend_dep = .{}, + .link = .{}, + }, +} diff --git a/backends/wgpu/build_fragments/backend_dep.txt b/backends/null/build_fragments/backend_dep.txt similarity index 54% rename from backends/wgpu/build_fragments/backend_dep.txt rename to backends/null/build_fragments/backend_dep.txt index e7e01da3..ecbee285 100644 --- a/backends/wgpu/build_fragments/backend_dep.txt +++ b/backends/null/build_fragments/backend_dep.txt @@ -1,7 +1,8 @@ - const backend_dep = b.dependency("labelle_wgpu", .{ .target = target, .optimize = optimize }); + // Null backend — pure Zig, no native artifact. Every backend module is + // a no-op stub; the generated main() drives a fixed-frame tick loop. + const backend_dep = b.dependency("labelle_null", .{ .target = target, .optimize = optimize }); const backend_gfx = backend_dep.module("gfx"); const backend_input = backend_dep.module("input"); const backend_audio = backend_dep.module("audio"); const backend_window = backend_dep.module("window"); - const glfw_artifact = backend_dep.artifact("glfw"); diff --git a/examples/wgpu/scripts/.gitkeep b/backends/null/build_fragments/link.txt similarity index 100% rename from examples/wgpu/scripts/.gitkeep rename to backends/null/build_fragments/link.txt diff --git a/backends/wgpu/backend.manifest.zon b/backends/wgpu/backend.manifest.zon deleted file mode 100644 index c33d11e8..00000000 --- a/backends/wgpu/backend.manifest.zon +++ /dev/null @@ -1,26 +0,0 @@ -.{ - // wgpu backend manifest — pluggable-backends epic (labelle-assembler#386). - // - // Presence of this file opts the wgpu DESKTOP build into the manifest-splice - // codegen path (manifest_splice.zig) instead of the enum `switch (cfg.backend)` - // sections in src/templates/build_zig.txt. The spliced output is byte-identical - // to the enum path (verified against a generated baseline) — this is the step - // that lets wgpu move out-of-tree (its build sections travel WITH the package). - // - // Loop-style (owns `while (!window.shouldQuit())`), desktop-only — wgpu has no - // WASM/Android target (the CLI rejects wgpu+wasm). No params: unlike bgfx, the - // wgpu fragments take no gamepad/gui toggles, and wgpu pulls no shared gamepad - // sub-package (no core-diamond override needed). - .dir_name = "wgpu", - .dep_name = "labelle_wgpu", - .loop_style = .loop, - .main_loop_template = "templates/desktop.txt", - .build_fragments = .{ - .backend_dep = "build_fragments/backend_dep.txt", - .link = "build_fragments/link.txt", - }, - .params = .{ - .backend_dep = .{}, - .link = .{}, - }, -} diff --git a/backends/wgpu/build.zig b/backends/wgpu/build.zig deleted file mode 100644 index 2bff2cbb..00000000 --- a/backends/wgpu/build.zig +++ /dev/null @@ -1,157 +0,0 @@ -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - // `wgpu_native_zig` is `lazy = true` in build.zig.zon. Zig 0.16 - // enforces this strictly — calling `b.dependency` on a lazy dep - // panics with "must use the lazyDependency function instead". - // Switch to `b.lazyDependency` and only wire wgpu-dependent - // imports when the dep is materialized. - // - // KNOWN BLOCKER (out of scope for #220, see PR body): upstream - // `apotema/wgpu_native_zig` @ fb54d9c8 is itself not yet Zig 0.16 - // compatible. Its own `build.zig` calls `linkFramework`, - // `addLibraryPath`, `addObjectFile` directly on `*Compile`, which - // 0.16 moved onto `*Build.Module`. The 0.16 build-runner compiles - // every transitive `build.zig` upfront, so any `zig build` (or - // even `zig build --help`) errors out on those upstream sites - // until the fork is rebased. This patch keeps the assembler-side - // surface consistent with PR #218's sweep so the migration is - // ready to merge as soon as upstream catches up. - const wgpu_dep_opt = b.lazyDependency("wgpu_native_zig", .{ .target = target, .optimize = optimize }); - const zglfw_dep = b.dependency("zglfw", .{ .target = target, .optimize = optimize }); - - const wgpu_mod_opt: ?*std.Build.Module = if (wgpu_dep_opt) |d| d.module("wgpu") else null; - const zglfw_mod = zglfw_dep.module("root"); - const glfw_artifact = zglfw_dep.artifact("glfw"); - - // Shared audio engine (pluggable-backends RFC, Phase 2). `src/audio.zig` - // now forwards to `labelle_audio.Mixer(NullSink)` — wgpu has no real OS - // device, so it injects the shared `NullSink` and pumps the i16 mix manually - // via `mixOutput`. Wired into the `audio` module (and the host audio test - // module) under the `labelle-audio` import key. The mixer/decoder are pure - // Zig, so this resolves on every target. - const labelle_audio_dep = b.dependency("labelle_audio", .{ .target = target, .optimize = optimize }); - const labelle_audio_mod = labelle_audio_dep.module("labelle-audio"); - - // ── Gfx backend module ────────────────────────────────────────── - // `link_libc = true` so the legacy `loadTexture` path-based loader - // can call libc `fopen` / `fread` / `fclose`. See the rationale - // block above `loadTexture` in src/gfx.zig. - const gfx_mod = b.addModule("gfx", .{ - .root_source_file = b.path("src/gfx.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }); - if (wgpu_mod_opt) |m| gfx_mod.addImport("wgpu", m); - - // ── Input backend module ──────────────────────────────────────── - const input_mod = b.addModule("input", .{ - .root_source_file = b.path("src/input.zig"), - .target = target, - .optimize = optimize, - }); - input_mod.addImport("zglfw", zglfw_mod); - - // ── Audio backend module ──────────────────────────────────────── - // `link_libc = true` so the path-based `loadSound`/`loadMusic` shim can - // call libc `fopen` / `fread` / `fclose`. See the rationale block above - // `readFileBytes` in src/audio.zig. - const audio_mod = b.addModule("audio", .{ - .root_source_file = b.path("src/audio.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - }); - // Shared WAV decode + PCM mixer (Phase 2). `audio.zig` instantiates - // `labelle_audio.Mixer(NullSink)` and forwards every public fn to it. - // wgpu has no native audio device dep — the mix is software-pumped. - audio_mod.addImport("labelle-audio", labelle_audio_mod); - - // ── Window backend module ─────────────────────────────────────── - const window_mod = b.addModule("window", .{ - .root_source_file = b.path("src/window.zig"), - .target = target, - .optimize = optimize, - }); - window_mod.addImport("zglfw", zglfw_mod); - if (wgpu_mod_opt) |m| window_mod.addImport("wgpu", m); - // window.zig hands the created GLFW window to the input module - // (`input.setWindow`) and pumps `input.newFrame()` per frame. - window_mod.addImport("input", input_mod); - // The render submitter in window.zig drains gfx.zig's shape batch - // (consumeShapeBatch) and routes drawText into it. - window_mod.addImport("gfx", gfx_mod); - - // ── Re-export native artifacts so consumers can link them ─────── - b.installArtifact(glfw_artifact); - - // ── Audio adapter smoke tests ────────────────────────────────── - // The WAV decode / mixer / spinlock / UAF behaviour now lives in (and is - // tested by) `labelle-audio` — the #12 overflow regression lock moved with - // the parser into `labelle-audio/src/wav.zig`. These thin tests confirm the - // wgpu adapter wires the shared `Mixer(NullSink)` correctly (the f32 - // `mixOutput` shim). They RUN (NullSink needs no device), so the test - // module is pinned to `host_target`. The shared mixer is pure Zig, so it - // resolves for the host without any native audio dep. - const host_target = b.resolveTargetQuery(.{}); - const audio_test_mod = b.createModule(.{ - .root_source_file = b.path("src/audio.zig"), - .target = host_target, - .optimize = optimize, - .link_libc = true, - }); - const labelle_audio_host_dep = b.dependency("labelle_audio", .{ .target = host_target, .optimize = optimize }); - audio_test_mod.addImport("labelle-audio", labelle_audio_host_dep.module("labelle-audio")); - const audio_tests = b.addTest(.{ .root_module = audio_test_mod }); - - // ── Unit tests for the CPU image decoders (PNG/BMP/TGA) ───────── - // gfx.zig only imports `std` (the `wgpu` import is gated behind the - // native artifact and unused by the decode path), so its decode - // tests build on any host. `link_libc = true` resolves the libc - // FILE externs used by the legacy `loadTexture` path-loader. - const gfx_tests = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("src/gfx.zig"), - .target = host_target, - .optimize = optimize, - .link_libc = true, - }), - }); - - // ── ASTC container-parsing tests (#341) ───────────────────────── - // `gfx/astc.zig` is pure byte parsing with no wgpu dependency, so it - // EXECUTES on the host (magic detection, block/image dims, ceil-to-block - // payload sizing, truncation) — a verbatim port of the bgfx backend's - // astc_run target. The wgpu-side seam tests (isCompressed / - // uploadCompressed / getCompressedTexture) ride in `gfx_tests` above, - // since they live in `gfx/texture.zig`. - const astc_run = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("src/gfx/astc.zig"), - .target = host_target, - .optimize = optimize, - }), - }); - - const test_step = b.step("test", "Run wgpu backend unit tests"); - test_step.dependOn(&b.addRunArtifact(audio_tests).step); - test_step.dependOn(&b.addRunArtifact(gfx_tests).step); - test_step.dependOn(&b.addRunArtifact(astc_run).step); - - // ── Compile-check window.zig ──────────────────────────────────── - // window.zig owns the GLFW window lifecycle + the fullscreen toggle - // (GLFW setMonitor → wgpu surface reconfigure). It references - // `wgpu.SurfaceConfiguration`, so it only compiles when the native - // wgpu dep is materialized. Gate the compile-check on the lazy dep so - // hosts without the wgpu artifact still build the rest of the test - // step; depend on the compile step (not a run step) so it works under - // cross-compilation where the produced binary can't be executed. - if (wgpu_mod_opt != null) { - const window_tests = b.addTest(.{ .root_module = window_mod }); - test_step.dependOn(&window_tests.step); - } -} diff --git a/backends/wgpu/build.zig.zon b/backends/wgpu/build.zig.zon deleted file mode 100644 index ed618eb7..00000000 --- a/backends/wgpu/build.zig.zon +++ /dev/null @@ -1,32 +0,0 @@ -.{ - .fingerprint = 0x993952ebf33b101a, - .name = .labelle_wgpu, - .version = "0.1.0", - .minimum_zig_version = "0.16.0", - .dependencies = .{ - .wgpu_native_zig = .{ - .url = "git+https://github.com/snorm-dev/wgpu_native_zig#8aef4a9873637ba9444282f7a706eaa251f8d8e9", - .hash = "wgpu_native_zig-6.5.0-B9jeDCtzAwADQTiKl7pWvLrNC7pelV0GlH_ePoIse28g", - .lazy = true, - }, - .zglfw = .{ - .url = "git+https://github.com/zig-gamedev/zglfw#51003c105d23db378bb59ce415a387b22f1b0892", - .hash = "zglfw-0.10.0-dev-zgVDNIy4IQDJNRy4jrP1As-SZxfJpuWhU1iJ-wBab_VD", - }, - // Shared audio engine (pluggable-backends RFC, Phase 2). The wgpu - // backend's `src/audio.zig` collapses its duplicated overflow-safe WAV - // decode + f32 PCM mixer onto `labelle_audio.Mixer(NullSink)` — wgpu has - // no real OS device, so it injects the shared `NullSink` and pumps the - // i16 mix manually via `mixOutput`. The generated app build must also - // wire this dep onto the `audio` module (mirrors the bgfx pilot). - .labelle_audio = .{ - .url = "https://github.com/labelle-toolkit/labelle-audio/archive/refs/tags/v0.2.1.tar.gz", - .hash = "labelle_audio-0.2.1-QQRmYI4eAQDChv7DVK74-HPS9_J2bJw5ZDKFOazffYjU", - }, - }, - .paths = .{ - "build.zig", - "build.zig.zon", - "src", - }, -} diff --git a/backends/wgpu/build_fragments/link.txt b/backends/wgpu/build_fragments/link.txt deleted file mode 100644 index 3c55096f..00000000 --- a/backends/wgpu/build_fragments/link.txt +++ /dev/null @@ -1,16 +0,0 @@ - exe.root_module.linkLibrary(glfw_artifact); - - // wgpu-native's static lib (libwgpu_native.a, embedded in the wgpu - // module via addObjectFile) references Apple system frameworks. Upstream - // links them per-Compile rather than on the module, so the consuming exe - // must link them here or the macOS/iOS link fails with undefined Metal / - // Foundation symbols. - switch (target.result.os.tag) { - .macos, .ios => { - exe.root_module.linkFramework("Foundation", .{}); - exe.root_module.linkFramework("QuartzCore", .{}); - exe.root_module.linkFramework("Metal", .{}); - }, - else => {}, - } - diff --git a/backends/wgpu/example/build.zig b/backends/wgpu/example/build.zig deleted file mode 100644 index 5ebba9fb..00000000 --- a/backends/wgpu/example/build.zig +++ /dev/null @@ -1,63 +0,0 @@ -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - // ── Fetch the WebGPU backend package (parent directory) ─────────── - const wgpu_backend = b.dependency("labelle_wgpu", .{ - .target = target, - .optimize = optimize, - }); - - // ── Build the example executable ───────────────────────────────── - const exe_mod = b.createModule(.{ - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - }); - - exe_mod.addImport("gfx", wgpu_backend.module("gfx")); - exe_mod.addImport("input", wgpu_backend.module("input")); - exe_mod.addImport("audio", wgpu_backend.module("audio")); - exe_mod.addImport("window", wgpu_backend.module("window")); - - const exe = b.addExecutable(.{ - .name = "wgpu-demo", - .root_module = exe_mod, - }); - - // Link native artifacts. - // Zig 0.16 moved `linkLibrary` / `linkSystemLibrary` / `addLibraryPath` - // (and friends like `addCSourceFile`, `addIncludePath`) from - // `*Build.Step.Compile` onto the executable's `root_module`. - // - // The actual WebGPU runtime is the wgpu-native static library - // (`libwgpu_native.a`), which `wgpu_native_zig` already embeds into its - // `wgpu` module via `addObjectFile`. That module is imported by the - // backend's `gfx`/`window` modules, so the native symbols travel into - // this exe transitively — no explicit wgpu library link is needed here. - // We only need the GLFW windowing artifact plus, on Apple platforms, the - // system frameworks wgpu-native depends on (Metal/QuartzCore/Foundation), - // which upstream links at the Compile step rather than on the module. - exe.root_module.linkLibrary(wgpu_backend.artifact("glfw")); - - const target_result = target.result; - if (target_result.os.tag == .macos or target_result.os.tag == .ios) { - exe.root_module.linkFramework("Foundation", .{}); - exe.root_module.linkFramework("QuartzCore", .{}); - exe.root_module.linkFramework("Metal", .{}); - } - - b.installArtifact(exe); - - // ── Run step ───────────────────────────────────────────────────── - const run_cmd = b.addRunArtifact(exe); - run_cmd.step.dependOn(b.getInstallStep()); - if (b.args) |args| { - run_cmd.addArgs(args); - } - - const run_step = b.step("run", "Run the WebGPU backend demo"); - run_step.dependOn(&run_cmd.step); -} diff --git a/backends/wgpu/example/build.zig.zon b/backends/wgpu/example/build.zig.zon deleted file mode 100644 index 2956c0d9..00000000 --- a/backends/wgpu/example/build.zig.zon +++ /dev/null @@ -1,16 +0,0 @@ -.{ - .fingerprint = 0x522707efe6030a65, - .name = .wgpu_demo, - .version = "0.1.0", - .minimum_zig_version = "0.16.0", - .dependencies = .{ - .labelle_wgpu = .{ - .path = "..", - }, - }, - .paths = .{ - "build.zig", - "build.zig.zon", - "main.zig", - }, -} diff --git a/backends/wgpu/example/main.zig b/backends/wgpu/example/main.zig deleted file mode 100644 index fb746f26..00000000 --- a/backends/wgpu/example/main.zig +++ /dev/null @@ -1,619 +0,0 @@ -/// LaBelle v2 — WebGPU Backend Demo -/// -/// A comprehensive example showcasing all WebGPU backend features: -/// - Procedural shapes: rectangles, circles, polygons, lines, triangles, text -/// - Camera with lerp follow, zoom, and reset -/// - Gizmo overlay (bounding boxes, labels, velocity arrows, grid) -/// - Audio: sound effects and music (WAV-based PCM mixer) -/// - Input: keyboard (WASD/arrows), mouse wheel zoom, toggles -/// - Animation: color cycling, alpha pulsing, rotation, orbital motion -/// -/// Controls: -/// WASD / Arrow keys — Move player -/// Space — Play sound effect -/// G — Toggle gizmo overlay -/// M — Toggle music playback -/// R — Reset camera zoom and position -/// Escape — Quit -/// Mouse wheel — Zoom in/out -const std = @import("std"); -const gfx = @import("gfx"); -const window = @import("window"); -const input = @import("input"); -const audio = @import("audio"); - -// ── GLFW key codes ──────────────────────────────────────────────────── - -const KEY_W = 87; -const KEY_A = 65; -const KEY_S = 83; -const KEY_D = 68; -const KEY_R = 82; -const KEY_G = 71; -const KEY_M = 77; -const KEY_SPACE = 32; -const KEY_ESCAPE = 256; -const KEY_UP = 265; -const KEY_DOWN = 264; -const KEY_LEFT = 263; -const KEY_RIGHT = 262; - -// ── Constants ───────────────────────────────────────────────────────── - -const SCREEN_W = 800; -const SCREEN_H = 600; -const PLAYER_SPEED = 200.0; -const CAMERA_LERP = 0.08; -const ZOOM_SPEED = 0.1; -const MIN_ZOOM = 0.25; -const MAX_ZOOM = 4.0; -const ENEMY_SPEED = 80.0; -const ORBITER_SPEED = 1.5; -const ORBITER_RADIUS = 120.0; -const GRID_SPACING = 100.0; - -// ── Entity state ────────────────────────────────────────────────────── - -const Entity = struct { - x: f32, - y: f32, - w: f32, - h: f32, - vx: f32 = 0, - vy: f32 = 0, - name: [:0]const u8, -}; - -const EnemyPatrol = struct { - entity: Entity, - start_x: f32, - end_x: f32, - direction: f32 = 1.0, -}; - -// ── Game state ──────────────────────────────────────────────────────── - -var player = Entity{ - .x = 400, - .y = 300, - .w = 60, - .h = 60, - .name = "Player", -}; - -var enemies: [3]EnemyPatrol = .{ - .{ - .entity = .{ .x = 200, .y = 450, .w = 30, .h = 30, .name = "Enemy A" }, - .start_x = 100, - .end_x = 350, - }, - .{ - .entity = .{ .x = 500, .y = 200, .w = 30, .h = 30, .name = "Enemy B" }, - .start_x = 400, - .end_x = 700, - }, - .{ - .entity = .{ .x = 600, .y = 400, .w = 30, .h = 30, .name = "Enemy C" }, - .start_x = 500, - .end_x = 750, - }, -}; - -const Platform = struct { - x: f32, - y: f32, - w: f32, - h: f32, -}; - -const platforms = [_]Platform{ - .{ .x = 50, .y = 520, .w = 300, .h = 20 }, - .{ .x = 400, .y = 480, .w = 250, .h = 20 }, - .{ .x = 150, .y = 350, .w = 200, .h = 20 }, - .{ .x = 500, .y = 300, .w = 180, .h = 20 }, - .{ .x = 0, .y = 580, .w = 800, .h = 20 }, // ground -}; - -var camera = gfx.Camera2D{ - .offset = .{ .x = @as(f32, SCREEN_W) / 2.0, .y = @as(f32, SCREEN_H) / 2.0 }, - .target = .{ .x = 400, .y = 300 }, - .rotation = 0, - .zoom = 1.0, -}; - -var time: f32 = 0; -var show_gizmos: bool = false; -var music_playing: bool = false; -var player_moving: bool = false; - -var sfx_id: u32 = 0; -var music_id: u32 = 0; - -// Procedurally-generated checkerboard sprite (proves the textured-quad path). -const SPRITE_SIZE = 32; -var sprite_tex: ?gfx.Texture = null; - -/// Build a 32x32 RGBA8 checkerboard in-memory and upload it as a GPU texture. -/// No asset file needed — exercises decode-free uploadTexture + the wgpu -/// sprite pipeline end to end. -fn makeCheckerSprite() ?gfx.Texture { - const S = struct { - var pixels: [SPRITE_SIZE * SPRITE_SIZE * 4]u8 = undefined; - }; - var y: usize = 0; - while (y < SPRITE_SIZE) : (y += 1) { - var x: usize = 0; - while (x < SPRITE_SIZE) : (x += 1) { - const cell = ((x / 4) + (y / 4)) % 2 == 0; - const i = (y * SPRITE_SIZE + x) * 4; - if (cell) { - S.pixels[i + 0] = 255; // R - S.pixels[i + 1] = 80; // G - S.pixels[i + 2] = 200; // B - S.pixels[i + 3] = 255; // A - } else { - S.pixels[i + 0] = 40; - S.pixels[i + 1] = 220; - S.pixels[i + 2] = 255; - S.pixels[i + 3] = 255; - } - } - } - return gfx.uploadTexture(.{ - .pixels = &S.pixels, - .width = SPRITE_SIZE, - .height = SPRITE_SIZE, - }) catch null; -} - -// ── Delta time (fixed step approximation) ───────────────────────────── - -const DT = 1.0 / 60.0; - -// ── Helper: lerp ────────────────────────────────────────────────────── - -fn lerp(a: f32, b: f32, t_val: f32) f32 { - return a + (b - a) * t_val; -} - -// ── Update ──────────────────────────────────────────────────────────── - -fn update() void { - time += DT; - - // --- Player movement --- - var dx: f32 = 0; - var dy: f32 = 0; - - if (input.isKeyDown(KEY_W) or input.isKeyDown(KEY_UP)) dy -= 1; - if (input.isKeyDown(KEY_S) or input.isKeyDown(KEY_DOWN)) dy += 1; - if (input.isKeyDown(KEY_A) or input.isKeyDown(KEY_LEFT)) dx -= 1; - if (input.isKeyDown(KEY_D) or input.isKeyDown(KEY_RIGHT)) dx += 1; - - // Normalize diagonal movement - const mag = @sqrt(dx * dx + dy * dy); - if (mag > 0) { - dx = dx / mag * PLAYER_SPEED * DT; - dy = dy / mag * PLAYER_SPEED * DT; - } - - player.vx = dx / DT; - player.vy = dy / DT; - player.x += dx; - player.y += dy; - player_moving = mag > 0; - - // Clamp player to world bounds - player.x = std.math.clamp(player.x, 0, 800 - player.w); - player.y = std.math.clamp(player.y, 0, 600 - player.h); - - // --- Enemy patrol --- - for (&enemies) |*ep| { - ep.entity.x += ENEMY_SPEED * ep.direction * DT; - if (ep.entity.x >= ep.end_x) { - ep.entity.x = ep.end_x; - ep.direction = -1.0; - } else if (ep.entity.x <= ep.start_x) { - ep.entity.x = ep.start_x; - ep.direction = 1.0; - } - ep.entity.vx = ENEMY_SPEED * ep.direction; - } - - // --- Camera follow with lerp --- - const target_x = player.x + player.w / 2.0; - const target_y = player.y + player.h / 2.0; - camera.target.x = lerp(camera.target.x, target_x, CAMERA_LERP); - camera.target.y = lerp(camera.target.y, target_y, CAMERA_LERP); - - // Mouse wheel zoom - const wheel = input.getMouseWheelMove(); - if (wheel != 0) { - camera.zoom += wheel * ZOOM_SPEED; - camera.zoom = std.math.clamp(camera.zoom, MIN_ZOOM, MAX_ZOOM); - } - - // --- Input: toggle gizmos / music (manual edge-detection since GLFW - // key callbacks may not be wired, so isKeyPressed may not fire) --- - { - const S = struct { - var g_was_down: bool = false; - var m_was_down: bool = false; - }; - - const g_down = input.isKeyDown(KEY_G); - if (g_down and !S.g_was_down) { - show_gizmos = !show_gizmos; - } - S.g_was_down = g_down; - - const m_down = input.isKeyDown(KEY_M); - if (m_down and !S.m_was_down) { - if (music_id != 0) { - if (music_playing) { - audio.pauseMusic(music_id); - music_playing = false; - } else { - audio.resumeMusic(music_id); - music_playing = true; - } - } - } - S.m_was_down = m_down; - } - - // --- Input: play sound effect --- - if (input.isKeyDown(KEY_SPACE)) { - if (sfx_id != 0) { - audio.playSound(sfx_id); - } - } - - // --- Input: reset camera --- - if (input.isKeyDown(KEY_R)) { - camera.zoom = 1.0; - camera.target.x = player.x + player.w / 2.0; - camera.target.y = player.y + player.h / 2.0; - } - - // --- Update music stream --- - if (music_id != 0) { - audio.updateMusic(music_id); - } -} - -// ── Render: World space ─────────────────────────────────────────────── - -fn renderWorld() void { - // --- Ground platforms (gray) --- - for (platforms) |p| { - gfx.drawRectangleRec( - .{ .x = p.x, .y = p.y, .width = p.w, .height = p.h }, - gfx.color(100, 100, 100, 255), - ); - } - - // --- Enemies (red circles with alpha pulsing) --- - for (&enemies) |*ep| { - const pulse = (@sin(time * 3.0 + ep.entity.x * 0.1) + 1.0) / 2.0; - const alpha: u8 = @intFromFloat(128.0 + pulse * 127.0); - const cx = ep.entity.x + ep.entity.w / 2.0; - const cy = ep.entity.y + ep.entity.h / 2.0; - gfx.drawCircle(cx, cy, ep.entity.w / 2.0, gfx.color(255, 60, 60, alpha)); - } - - // --- Player (green rectangle, color cycles when moving) --- - { - var pr: u8 = 30; - var pg: u8 = 200; - var pb: u8 = 60; - if (player_moving) { - // Cycle green channel with time - const cycle = (@sin(time * 8.0) + 1.0) / 2.0; - pg = @intFromFloat(120.0 + cycle * 135.0); - pb = @intFromFloat(30.0 + cycle * 80.0); - pr = @intFromFloat(20.0 + cycle * 40.0); - } - gfx.drawRectangleRec( - .{ .x = player.x, .y = player.y, .width = player.w, .height = player.h }, - gfx.color(pr, pg, pb, 255), - ); - } - - // --- Spinning hexagon (center of world) --- - { - const hex_x: f32 = 400; - const hex_y: f32 = 250; - const hex_radius: f32 = 40; - const rotation = time * 60.0; // degrees per second - // Color shifts over time - const cr: u8 = @intFromFloat((@sin(time * 1.0) + 1.0) / 2.0 * 200.0 + 55.0); - const cg: u8 = @intFromFloat((@sin(time * 1.3 + 1.0) + 1.0) / 2.0 * 200.0 + 55.0); - const cb: u8 = @intFromFloat((@sin(time * 1.7 + 2.0) + 1.0) / 2.0 * 200.0 + 55.0); - gfx.drawPoly(hex_x, hex_y, 6, hex_radius, rotation, gfx.color(cr, cg, cb, 220)); - } - - // --- Orbiter (blue circle on sin/cos path) --- - { - const orbit_cx: f32 = 600; - const orbit_cy: f32 = 250; - const ox = orbit_cx + @cos(time * ORBITER_SPEED) * ORBITER_RADIUS; - const oy = orbit_cy + @sin(time * ORBITER_SPEED) * ORBITER_RADIUS; - // Draw orbit path as dashed circle (8 line segments) - { - const segments: u32 = 32; - var i: u32 = 0; - while (i < segments) : (i += 2) { - const a0 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi; - const a1 = (@as(f32, @floatFromInt(i + 1)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi; - gfx.drawLine( - orbit_cx + @cos(a0) * ORBITER_RADIUS, - orbit_cy + @sin(a0) * ORBITER_RADIUS, - orbit_cx + @cos(a1) * ORBITER_RADIUS, - orbit_cy + @sin(a1) * ORBITER_RADIUS, - 1.0, - gfx.color(40, 80, 180, 80), - ); - } - } - gfx.drawCircle(ox, oy, 14, gfx.color(60, 120, 255, 240)); - } - - // --- Decorative triangle --- - gfx.drawTriangle( - .{ .x = 100, .y = 150 }, - .{ .x = 140, .y = 200 }, - .{ .x = 60, .y = 200 }, - gfx.color(255, 200, 50, 200), - ); - - // --- Textured sprite (checkerboard) — proves the wgpu sprite path --- - if (sprite_tex) |tex| { - const src = gfx.Rectangle{ .x = 0, .y = 0, .width = SPRITE_SIZE, .height = SPRITE_SIZE }; - // Two instances at different scales/rotations to show batching by texture. - const spin = time * 90.0; // degrees/sec - gfx.drawTexturePro( - tex, - src, - .{ .x = 250, .y = 250, .width = 96, .height = 96 }, - .{ .x = 48, .y = 48 }, // rotate about center - spin, - gfx.white, - ); - // Tinted, pulsing-alpha copy near the player. - const pulse: u8 = @intFromFloat(128.0 + (@sin(time * 4.0) + 1.0) / 2.0 * 127.0); - gfx.drawTexturePro( - tex, - src, - .{ .x = player.x, .y = player.y - 70, .width = 48, .height = 48 }, - .{ .x = 0, .y = 0 }, - 0, - gfx.color(255, 255, 255, pulse), - ); - } -} - -// ── Render: Gizmos (world space) ────────────────────────────────────── - -fn renderGizmos() void { - if (!show_gizmos) return; - - // Grid overlay - { - var gx: f32 = 0; - while (gx <= 800) : (gx += GRID_SPACING) { - gfx.drawLine(gx, 0, gx, 600, 1.0, gfx.color(255, 255, 255, 30)); - } - var gy: f32 = 0; - while (gy <= 600) : (gy += GRID_SPACING) { - gfx.drawLine(0, gy, 800, gy, 1.0, gfx.color(255, 255, 255, 30)); - } - } - - // Player bounding box - drawBoundingBox(player.x, player.y, player.w, player.h, gfx.color(0, 255, 0, 180)); - // Player name label - gfx.drawText(player.name, player.x, player.y - 14, 10, gfx.color(0, 255, 0, 200)); - // Player velocity arrow - if (player_moving) { - drawVelocityArrow( - player.x + player.w / 2.0, - player.y + player.h / 2.0, - player.vx, - player.vy, - gfx.color(0, 255, 0, 150), - ); - } - - // Enemy gizmos - for (&enemies) |*ep| { - const e = &ep.entity; - drawBoundingBox(e.x, e.y, e.w, e.h, gfx.color(255, 60, 60, 150)); - gfx.drawText(e.name, e.x, e.y - 14, 10, gfx.color(255, 100, 100, 200)); - drawVelocityArrow( - e.x + e.w / 2.0, - e.y + e.h / 2.0, - e.vx, - 0, - gfx.color(255, 60, 60, 120), - ); - // Patrol range indicator - gfx.drawLine( - ep.start_x, - e.y + e.h + 4, - ep.end_x, - e.y + e.h + 4, - 1.0, - gfx.color(255, 60, 60, 80), - ); - } -} - -fn drawBoundingBox(x: f32, y: f32, w: f32, h: f32, col: gfx.Color) void { - gfx.drawLine(x, y, x + w, y, 1.0, col); // top - gfx.drawLine(x + w, y, x + w, y + h, 1.0, col); // right - gfx.drawLine(x + w, y + h, x, y + h, 1.0, col); // bottom - gfx.drawLine(x, y + h, x, y, 1.0, col); // left -} - -fn drawVelocityArrow(cx: f32, cy: f32, vx: f32, vy: f32, col: gfx.Color) void { - const scale = 0.15; - const end_x = cx + vx * scale; - const end_y = cy + vy * scale; - gfx.drawLine(cx, cy, end_x, end_y, 2.0, col); - // Arrowhead (small triangle at end) - const dx = end_x - cx; - const dy = end_y - cy; - const mag = @sqrt(dx * dx + dy * dy); - if (mag > 2.0) { - const nx = dx / mag; - const ny = dy / mag; - const arrow_size: f32 = 6.0; - const px = -ny * arrow_size; // perpendicular - const py = nx * arrow_size; - gfx.drawTriangle( - .{ .x = end_x, .y = end_y }, - .{ .x = end_x - nx * arrow_size + px, .y = end_y - ny * arrow_size + py }, - .{ .x = end_x - nx * arrow_size - px, .y = end_y - ny * arrow_size - py }, - col, - ); - } -} - -// ── Render: HUD (screen space, no camera) ───────────────────────────── - -fn renderHud() void { - // Title - gfx.drawText("LaBelle v2 - WebGPU Demo", 10, 10, 20, gfx.white); - - // Controls help - gfx.drawText("WASD: Move G: Gizmos M: Music R: Reset Esc: Quit", 10, 36, 10, gfx.color(180, 180, 180, 200)); - - // Status line - { - const zoom_pct: i32 = @intFromFloat(camera.zoom * 100.0); - _ = zoom_pct; - // Since drawText takes [:0]const u8 and we cannot do runtime formatting - // easily without an allocator, show static indicators. - if (show_gizmos) { - gfx.drawText("[GIZMOS ON]", 10, 56, 12, gfx.color(0, 255, 0, 200)); - } - if (music_playing) { - gfx.drawText("[MUSIC ON]", 130, 56, 12, gfx.color(100, 180, 255, 200)); - } - } - - // FPS placeholder (static text since we lack runtime formatting without allocator) - gfx.drawText("60 FPS", SCREEN_W - 80, 10, 14, gfx.color(200, 200, 100, 220)); - - // Crosshair at screen center - const cx = @as(f32, SCREEN_W) / 2.0; - const cy = @as(f32, SCREEN_H) / 2.0; - gfx.drawLine(cx - 8, cy, cx + 8, cy, 1.0, gfx.color(255, 255, 255, 60)); - gfx.drawLine(cx, cy - 8, cx, cy + 8, 1.0, gfx.color(255, 255, 255, 60)); - - // Minimap outline (bottom-right) - const mm_x: f32 = SCREEN_W - 170; - const mm_y: f32 = SCREEN_H - 130; - const mm_w: f32 = 160; - const mm_h: f32 = 120; - // Background - gfx.drawRectangleRec( - .{ .x = mm_x, .y = mm_y, .width = mm_w, .height = mm_h }, - gfx.color(20, 20, 30, 180), - ); - // Border - drawBoundingBox(mm_x, mm_y, mm_w, mm_h, gfx.color(100, 100, 120, 200)); - - // Minimap entities (scaled down: world 800x600 -> minimap 160x120) - const sx = mm_w / 800.0; - const sy = mm_h / 600.0; - - // Platforms on minimap - for (platforms) |p| { - gfx.drawRectangleRec( - .{ .x = mm_x + p.x * sx, .y = mm_y + p.y * sy, .width = p.w * sx, .height = @max(p.h * sy, 1.0) }, - gfx.color(80, 80, 80, 200), - ); - } - - // Player on minimap - gfx.drawRectangleRec( - .{ .x = mm_x + player.x * sx, .y = mm_y + player.y * sy, .width = @max(player.w * sx, 3.0), .height = @max(player.h * sy, 3.0) }, - gfx.color(0, 200, 60, 255), - ); - - // Enemies on minimap - for (&enemies) |*ep| { - gfx.drawCircle( - mm_x + ep.entity.x * sx + ep.entity.w * sx / 2.0, - mm_y + ep.entity.y * sy + ep.entity.h * sy / 2.0, - 2.0, - gfx.color(255, 60, 60, 255), - ); - } - - // Camera viewport indicator on minimap - { - const half_w = (@as(f32, SCREEN_W) / 2.0) / camera.zoom; - const half_h = (@as(f32, SCREEN_H) / 2.0) / camera.zoom; - const vx = mm_x + (camera.target.x - half_w) * sx; - const vy = mm_y + (camera.target.y - half_h) * sy; - const vw = (half_w * 2.0) * sx; - const vh = (half_h * 2.0) * sy; - drawBoundingBox(vx, vy, vw, vh, gfx.color(255, 255, 0, 120)); - } -} - -// ── Main ────────────────────────────────────────────────────────────── - -pub fn main() void { - // --- Initialize window --- - window.initWindow(SCREEN_W, SCREEN_H, "LaBelle v2 \xe2\x80\x94 WebGPU Backend Demo"); - window.setTargetFPS(60); - gfx.setScreenSize(SCREEN_W, SCREEN_H); - - // --- Create the checkerboard sprite (in-memory, no asset file) --- - sprite_tex = makeCheckerSprite(); - - // --- Load audio assets (best-effort, files may not exist) --- - sfx_id = audio.loadSound("assets/jump.wav"); - music_id = audio.loadMusic("assets/bgm.wav"); - - // Auto-play music if loaded - if (music_id != 0) { - audio.playMusic(music_id); - audio.setMusicVolume(music_id, 0.5); - music_playing = true; - } - - // --- Main loop --- - while (!window.windowShouldClose()) { - // Check for quit - if (input.isKeyDown(KEY_ESCAPE)) break; - - // --- Update --- - update(); - - // --- Render --- - window.beginDrawing(); - window.clearBackground(30, 30, 46, 255); - - // World-space rendering (affected by camera) - gfx.beginMode2D(camera); - renderWorld(); - renderGizmos(); - gfx.endMode2D(); - - // Screen-space HUD (no camera transform) - renderHud(); - - window.endDrawing(); - } - - // --- Cleanup --- - if (sprite_tex) |tex| gfx.unloadTexture(tex); - if (sfx_id != 0) audio.unloadSound(sfx_id); - if (music_id != 0) audio.unloadMusic(music_id); - window.closeWindow(); -} diff --git a/backends/wgpu/src/audio.zig b/backends/wgpu/src/audio.zig deleted file mode 100644 index af7f083f..00000000 --- a/backends/wgpu/src/audio.zig +++ /dev/null @@ -1,242 +0,0 @@ -/// WebGPU audio backend — satisfies the engine AudioInterface(Impl) contract. -/// -/// Phase 2 of the pluggable-backends RFC (fan-out of the bgfx pilot): the WAV -/// decode + PCM mixer + slot management that this file used to reimplement -/// (~290 lines here, plus the ~415-line `wav_parser.zig`) now live in the -/// shared `labelle-audio` package. This file is a thin adapter. -/// -/// WebGPU has no audio API and there is **no real OS playback device** behind -/// this backend — nothing pumps a device callback. So this adapter instantiates -/// the shared mixer over the shared `NullSink`: -/// -/// * `Mixer(NullSink)` is fully usable software-pumped: `loadSoundFromMemory` -/// / `playSound` / `mix` etc. all work without a device thread. `NullSink` -/// records the mix callback (`ensureStarted`) but never invokes it, so the -/// host is responsible for pulling mixed samples — exactly the old wgpu -/// contract, where higher-level code fed mixed PCM to the real device. -/// * Every `pub fn` below forwards to `Audio.*`, preserving wgpu's public -/// audio API names + signatures verbatim (the engine/assembler call them by -/// name). -/// * The only wgpu-specific logic that remains is the libc file-read shim -/// behind the path-based `loadSound`/`loadMusic` and the f32 `mixOutput` -/// adapter (see below). -/// -/// ## i16 vs f32 -/// -/// The shared mixer is **i16**; wgpu's old mixer was f32. wgpu's f32 mix output -/// was software-only and had **no consumer** anywhere in the assembler, the -/// templates, or the engine wiring (`mixOutput` is referenced nowhere outside -/// this file), so collapsing onto the i16 mixer loses nothing. To keep the -/// public `mixOutput(output: []f32, frame_count: usize)` signature byte-for-byte -/// (it's part of wgpu's exposed surface), the adapter mixes into a small i16 -/// scratch buffer and converts to normalized [-1, 1] f32 — the same range the -/// old f32 mixer produced. If a future device path needs to consume mixed f32 -/// directly at scale, lift the conversion into the shared mixer (see the -/// `TODO(f32)` in `labelle-audio/src/device_sink.zig`) rather than re-growing a -/// per-backend mixer here. -/// -/// Thread-safety, the unload/mix UAF fix, the spinlock, mono→stereo -/// duplication, and the overflow-safe WAV decode are all provided by the shared -/// mixer (see `labelle-audio/src/mixer.zig` + `wav.zig`); nothing about that -/// behaviour changes here. -const std = @import("std"); -const labelle_audio = @import("labelle-audio"); - -/// The shared PCM mixer, parameterized by the shared `NullSink` (wgpu has no -/// real OS device — it's software-pumped via `mixOutput`). Owns WAV decode + -/// slot arrays + the spinlock + the full AudioInterface surface; the public fns -/// below forward to it. -const Audio = labelle_audio.Mixer(labelle_audio.NullSink); - -// ── Path-based file-read shim ──────────────────────────────────────── -// -// The shared mixer is byte-buffer based (`loadSoundFromMemory`), but wgpu's -// public `loadSound`/`loadMusic` take a file path. Zig 0.16 removed -// `std.fs.cwd()` in favour of `std.Io.Dir.cwd()`, which requires an `Io` -// threaded through the call site. Rather than thread `Io` through the backend -// for a one-shot legacy loader, we read the file via libc `fopen`/`fread`/ -// `fclose` — `link_libc = true` is set on the audio module (see -// backends/wgpu/build.zig), so libc is available at no extra cost. The decoded -// bytes are then handed to the shared mixer, which owns decode + ownership. - -const SEEK_SET: c_int = 0; -const SEEK_END: c_int = 2; -extern "c" fn fseek(stream: *std.c.FILE, offset: c_long, whence: c_int) c_int; -extern "c" fn ftell(stream: *std.c.FILE) c_long; - -/// Read an entire file into a freshly page-allocated buffer via libc. Returns -/// null on any IO error or short read (a short `fread` can occur on EOF -/// mid-read without setting an error flag, so we compare against the full -/// requested size). Caller owns the returned slice and frees it via -/// `std.heap.page_allocator`. -fn readFileBytes(path: [:0]const u8) ?[]u8 { - const file = std.c.fopen(path.ptr, "rb") orelse return null; - defer _ = std.c.fclose(file); - - if (fseek(file, 0, SEEK_END) != 0) return null; - const file_size_signed = ftell(file); - if (file_size_signed < 12) return null; // minimum RIFF/WAVE header - if (fseek(file, 0, SEEK_SET) != 0) return null; - const file_size: usize = @intCast(file_size_signed); - - const allocator = std.heap.page_allocator; - const data = allocator.alloc(u8, file_size) catch return null; - - const bytes_read = std.c.fread(data.ptr, 1, file_size, file); - if (bytes_read != file_size) { - allocator.free(data); - return null; - } - return data; -} - -// ── Sound effects ────────────────────────────────────────────────────── - -/// Load a WAV file from `path` and register it as a sound effect. Reads the -/// file via the libc shim, then hands the bytes to the shared mixer (which owns -/// decode + the PCM). Returns the sound id, or 0 on failure. -pub fn loadSound(path: [:0]const u8) u32 { - const bytes = readFileBytes(path) orelse return 0; - defer std.heap.page_allocator.free(bytes); - return Audio.loadSoundFromMemory(bytes); -} - -pub fn unloadSound(id: u32) void { - Audio.unloadSound(id); -} - -pub fn playSound(id: u32) void { - Audio.playSound(id); -} - -pub fn stopSound(id: u32) void { - Audio.stopSound(id); -} - -pub fn isSoundPlaying(id: u32) bool { - return Audio.isSoundPlaying(id); -} - -pub fn setSoundVolume(id: u32, volume: f32) void { - Audio.setSoundVolume(id, volume); -} - -// ── Music (streaming) ────────────────────────────────────────────────── - -/// Load a WAV file from `path` and register it as a looping music stream. Same -/// libc file-read shim as `loadSound`. Returns the music id, or 0 on failure. -pub fn loadMusic(path: [:0]const u8) u32 { - const bytes = readFileBytes(path) orelse return 0; - defer std.heap.page_allocator.free(bytes); - return Audio.loadMusicFromMemory(bytes); -} - -pub fn unloadMusic(id: u32) void { - Audio.unloadMusic(id); -} - -pub fn playMusic(id: u32) void { - Audio.playMusic(id); -} - -pub fn stopMusic(id: u32) void { - Audio.stopMusic(id); -} - -pub fn pauseMusic(id: u32) void { - Audio.pauseMusic(id); -} - -pub fn resumeMusic(id: u32) void { - Audio.resumeMusic(id); -} - -pub fn isMusicPlaying(id: u32) bool { - return Audio.isMusicPlaying(id); -} - -pub fn setMusicVolume(id: u32, volume: f32) void { - Audio.setMusicVolume(id, volume); -} - -pub fn updateMusic(id: u32) void { - Audio.updateMusic(id); -} - -// ── Global ──────────────────────────────────────────────────────────── - -pub fn setVolume(volume: f32) void { - Audio.setVolume(volume); -} - -/// Software mixer: mix all active sounds and music into an output buffer. -/// `output` is interleaved stereo f32, `frame_count` is the number of stereo -/// frames. Since wgpu has no real OS device, the host calls this on its own -/// tick to pull mixed samples (the old contract). -/// -/// The shared mixer is i16, so we mix `frame_count` stereo frames into a small -/// i16 scratch buffer (chunked, so an arbitrarily large `frame_count` never -/// needs an unbounded stack/heap buffer) and convert each sample to normalized -/// [-1, 1] f32 — matching the range the old f32 mixer produced. `output` is -/// always written for `min(frame_count * 2, output.len)` samples; any tail is -/// left untouched (the old mixer only wrote `mix_samples` too). -pub fn mixOutput(output: []f32, frame_count: usize) void { - const CHANNELS: usize = 2; - // Clamp to whole stereo frames BEFORE multiplying — `frame_count * CHANNELS` - // would overflow/trap for a huge caller value. Bounding frames by - // `output.len / CHANNELS` first keeps the product ≤ output.len, and makes - // `mix_samples` always an even (frame-aligned) count. - const frames = @min(frame_count, output.len / CHANNELS); - const mix_samples = frames * CHANNELS; - - // i16 scratch, processed in frame-aligned chunks so a huge `frame_count` - // doesn't blow the stack. 1024 stereo frames = 2048 i16 = 4 KiB. - var scratch: [2048]i16 = undefined; - - var done: usize = 0; - while (done < mix_samples) { - const remaining = mix_samples - done; - // Keep the chunk frame-aligned (even sample count) so the mixer's - // stereo interleave stays correct across chunk boundaries. - var chunk: usize = @min(remaining, scratch.len); - chunk -= chunk % CHANNELS; - if (chunk == 0) break; - - Audio.mix(scratch[0..chunk], CHANNELS); - for (0..chunk) |i| { - output[done + i] = @as(f32, @floatFromInt(scratch[i])) / 32768.0; - } - done += chunk; - } - // No partial-tail handling needed: `mix_samples` is frame-aligned (even), so - // the loop writes all of `[0..mix_samples]` with no early break, and the - // caller owns `[mix_samples..]` per this fn's "only writes the frames asked - // for" contract (Gemini's odd-sample break can't occur now that frames are - // clamped before the multiply). -} - -// ── Tests ───────────────────────────────────────────────────────────── -// -// The decode/mixer/spinlock/UAF behaviour is now tested in `labelle-audio` -// itself. These thin smoke tests confirm the wgpu adapter wires the shared -// mixer correctly (forwarding + the f32 `mixOutput` shim), exercised headlessly -// via `NullSink` (no device). - -const testing = std.testing; - -test "mixOutput clears output when nothing is playing" { - Audio.resetForTest(); - var buf = [_]f32{ 0.5, -0.5, 0.25, -0.25 }; // 2 stereo frames - mixOutput(&buf, 2); - for (buf) |s| try testing.expectEqual(@as(f32, 0), s); -} - -test "mixOutput only writes the frames it is asked for" { - Audio.resetForTest(); - // Ask for 1 stereo frame into a 3-frame buffer; the tail is untouched. - var buf = [_]f32{ 9, 9, 9, 9, 9, 9 }; - mixOutput(&buf, 1); - try testing.expectEqual(@as(f32, 0), buf[0]); - try testing.expectEqual(@as(f32, 0), buf[1]); - try testing.expectEqual(@as(f32, 9), buf[2]); -} diff --git a/backends/wgpu/src/gfx.zig b/backends/wgpu/src/gfx.zig deleted file mode 100644 index fae960a8..00000000 --- a/backends/wgpu/src/gfx.zig +++ /dev/null @@ -1,490 +0,0 @@ -/// WebGPU gfx backend — satisfies the labelle-gfx Backend(Impl) contract. -/// Uses wgpu_native_zig (wgpu-native Zig bindings) for GPU rendering with -/// vertex batching. -/// -/// This file is the public façade for the wgpu gfx backend. The -/// implementation is split across `gfx/` submodules to keep each concern -/// below the 1000-line ceiling enforced by labelle-assembler#188 (this -/// file was ~1710 lines before the split): -/// -/// - `gfx/types.zig` — value types (Texture, Color, …) + color -/// constants + the ColorVertex/SpriteVertex -/// vertex formats. -/// - `gfx/state.zig` — screen / camera state + coordinate helpers. -/// This is where the HiDPI/Retina TWO-SIZE -/// model lives (physical `screen_w/h` vs. logical -/// `design_w/h` + aspect-fit), ported from the -/// bgfx backend (v0.42.0). `toNdcX/Y` map against -/// the design canvas then aspect-fit into the -/// physical framebuffer; `screenToDesign` maps -/// physical input back to design space. -/// - `gfx/batch.zig` — shape/sprite vertex+index batch state, the -/// ordered draw-segment stream, and `consumeFrame`. -/// - `gfx/draw.zig` — shape primitives (rect/circle/line/triangle/ -/// poly) + the textured-quad sprite draw. -/// - `gfx/texture.zig` — texture-slot pool + image decode (PNG/BMP/TGA) -/// + load/upload/unload + getTexturePixels. -/// - `gfx/font.zig` — embedded 8x8 bitmap font + glyph atlas + drawText. -/// -/// Submodules are private file-system neighbours. The public surface is -/// consumed via `b.dependency("labelle_wgpu", ...).module("gfx")`, which -/// still points at this file. -const std = @import("std"); - -const types = @import("gfx/types.zig"); -const state = @import("gfx/state.zig"); -const batch = @import("gfx/batch.zig"); -const draw = @import("gfx/draw.zig"); -const texture = @import("gfx/texture.zig"); -const font = @import("gfx/font.zig"); - -// ── Backend types ────────────────────────────────────────────────────── - -pub const Texture = types.Texture; -pub const Color = types.Color; -pub const Rectangle = types.Rectangle; -pub const Vector2 = types.Vector2; -pub const Camera2D = types.Camera2D; - -// ── Color constants ──────────────────────────────────────────────────── - -pub const white = types.white; -pub const black = types.black; -pub const red = types.red; -pub const green = types.green; -pub const blue = types.blue; -pub const transparent = types.transparent; - -pub const color = types.color; - -// ── Vertex types (consumed by the window submitter) ──────────────────── - -pub const ColorVertex = types.ColorVertex; -pub const SpriteVertex = types.SpriteVertex; - -// ── State / coordinate model (HiDPI two-size) ────────────────────────── - -pub const setScreenSize = state.setScreenSize; -// Physical↔design coordinate conversion for HiDPI input mapping. The -// camera's `framebufferToWorld` calls `screenToDesign` (guarded by -// `@hasDecl`) so mouse in framebuffer pixels maps to design space. -pub const screenToDesign = state.screenToDesign; -pub const designToPhysical = state.designToPhysical; -pub const getDesignWidth = state.getDesignWidth; -pub const getDesignHeight = state.getDesignHeight; -pub const setDesignSize = state.setDesignSize; -pub const beginMode2D = state.beginMode2D; -pub const endMode2D = state.endMode2D; -pub const getScreenWidth = state.getScreenWidth; -pub const getScreenHeight = state.getScreenHeight; -pub const screenToWorld = state.screenToWorld; -pub const worldToScreen = state.worldToScreen; - -// ── Batch / frame consumption (Backend contract) ─────────────────────── - -pub const SegmentKind = batch.SegmentKind; -pub const DrawSegment = batch.DrawSegment; -pub const Frame = batch.Frame; -pub const resetShapeBatch = batch.resetShapeBatch; -pub const resetSpriteBatch = batch.resetSpriteBatch; -pub const consumeShapeBatch = batch.consumeShapeBatch; -pub const consumeSpriteBatch = batch.consumeSpriteBatch; -/// Backward-compatible alias for `consumeShapeBatch`. -pub const getShapeBatch = batch.consumeShapeBatch; -/// Backward-compatible alias for `consumeSpriteBatch`. -pub const getSpriteBatch = batch.consumeSpriteBatch; -pub const consumeFrame = batch.consumeFrame; - -// ── Draw primitives (Backend contract) ───────────────────────────────── - -pub const drawRectangleRec = draw.drawRectangleRec; -pub const drawCircle = draw.drawCircle; -pub const drawLine = draw.drawLine; -pub const drawTriangle = draw.drawTriangle; -pub const drawPolygon = draw.drawPolygon; -pub const drawPoly = draw.drawPoly; -pub const drawTexturePro = draw.drawTexturePro; - -// ── Texture / Sprite rendering ───────────────────────────────────────── - -pub const DecodedImage = texture.DecodedImage; -pub const TexturePixels = texture.TexturePixels; -pub const loadTexture = texture.loadTexture; -pub const decodeImage = texture.decodeImage; -pub const uploadTexture = texture.uploadTexture; -pub const unloadTexture = texture.unloadTexture; -pub const getTexturePixels = texture.getTexturePixels; -// GPU-compressed (ASTC) upload — the labelle-gfx `loadTextureFromMemory` seam -// dispatches to `isCompressed`/`uploadCompressed` via `@hasDecl` when the blob -// is compressed (#341). `getCompressedTexture` is read by the window submitter -// to build the ASTC wgpu texture lazily on the main thread. -pub const isCompressed = texture.isCompressed; -pub const uploadCompressed = texture.uploadCompressed; -// Header-only dims for the async asset-catalog adapter (engine#450), which -// splits worker-thread decode from main-thread upload and so can't use the -// synchronous seam — it reads dims here to set DecodedImage before upload. -pub const compressedDims = texture.compressedDims; -pub const CompressedTexture = texture.CompressedTexture; -pub const getCompressedTexture = texture.getCompressedTexture; - -// ── Text rendering ───────────────────────────────────────────────────── - -pub const drawText = font.drawText; - -// ══════════════════════════════════════════════════════════════════════ -// Tests — pure-CPU; no GPU needed. They drive the public façade surface so -// they exercise the same call paths real consumers use after the split. -// ══════════════════════════════════════════════════════════════════════ - -// Re-import the decode helpers + font internals the tests poke directly. -const decodePng = texture.decodePng; -const ensureFontAtlas = font.ensureFontAtlas; -const buildFontAtlasPixels = font.buildFontAtlasPixels; - -// ── Ordered draw-segment tests ───────────────────────────────────────── -// Drive the draw API and assert consumeFrame() yields segments in -// submission order with correct index/quad ranges. No GPU needed. - -test "draw segments: shape -> sprite -> shape preserves submission order" { - // Clear any state leaked from a prior test in this process. - _ = consumeFrame(); - setScreenSize(800, 600); - setDesignSize(800, 600); - - const tex = Texture{ .id = 1, .width = 16, .height = 16 }; - - // Shape (rect = 6 indices), then sprite (1 quad = 6 indices), then shape. - drawRectangleRec(.{ .x = 0, .y = 0, .width = 10, .height = 10 }, white); - drawTexturePro(tex, .{ .x = 0, .y = 0, .width = 16, .height = 16 }, .{ .x = 0, .y = 0, .width = 16, .height = 16 }, .{ .x = 0, .y = 0 }, 0, white); - drawRectangleRec(.{ .x = 20, .y = 20, .width = 10, .height = 10 }, red); - - const frame = consumeFrame(); - - try std.testing.expectEqual(@as(usize, 3), frame.segments.len); - - // Segment 0: shape, first 6 shape indices. - try std.testing.expectEqual(SegmentKind.shape, frame.segments[0].kind); - try std.testing.expectEqual(@as(u32, 0), frame.segments[0].index_start); - try std.testing.expectEqual(@as(u32, 6), frame.segments[0].index_count); - - // Segment 1: sprite, first 6 sprite indices, quad 0. - try std.testing.expectEqual(SegmentKind.sprite, frame.segments[1].kind); - try std.testing.expectEqual(@as(u32, 0), frame.segments[1].index_start); - try std.testing.expectEqual(@as(u32, 6), frame.segments[1].index_count); - try std.testing.expectEqual(@as(u32, 0), frame.segments[1].quad_start); - try std.testing.expectEqual(@as(u32, 1), frame.segments[1].quad_count); - - // Segment 2: shape, next 6 shape indices (offset 6, since the sprite - // lives in a SEPARATE index buffer). - try std.testing.expectEqual(SegmentKind.shape, frame.segments[2].kind); - try std.testing.expectEqual(@as(u32, 6), frame.segments[2].index_start); - try std.testing.expectEqual(@as(u32, 6), frame.segments[2].index_count); - - // Buffers: 2 shape rects = 8 verts / 12 indices; 1 sprite = 4 verts / 6 - // indices / 1 texture id. - try std.testing.expectEqual(@as(usize, 8), frame.shape_vertices.len); - try std.testing.expectEqual(@as(usize, 12), frame.shape_indices.len); - try std.testing.expectEqual(@as(usize, 4), frame.sprite_vertices.len); - try std.testing.expectEqual(@as(usize, 6), frame.sprite_indices.len); - try std.testing.expectEqual(@as(usize, 1), frame.sprite_texture_ids.len); - try std.testing.expectEqual(@as(u32, 1), frame.sprite_texture_ids[0]); -} - -test "draw segments: consecutive same-kind draws coalesce into one segment" { - _ = consumeFrame(); - setScreenSize(800, 600); - setDesignSize(800, 600); - - const tex = Texture{ .id = 2, .width = 16, .height = 16 }; - - // sprite, sprite, shape: the two sprites must merge into one segment. - drawTexturePro(tex, .{ .x = 0, .y = 0, .width = 16, .height = 16 }, .{ .x = 0, .y = 0, .width = 16, .height = 16 }, .{ .x = 0, .y = 0 }, 0, white); - drawTexturePro(tex, .{ .x = 0, .y = 0, .width = 16, .height = 16 }, .{ .x = 16, .y = 0, .width = 16, .height = 16 }, .{ .x = 0, .y = 0 }, 0, white); - drawRectangleRec(.{ .x = 0, .y = 0, .width = 10, .height = 10 }, white); - - const frame = consumeFrame(); - - try std.testing.expectEqual(@as(usize, 2), frame.segments.len); - - // Segment 0: one sprite segment spanning both quads. - try std.testing.expectEqual(SegmentKind.sprite, frame.segments[0].kind); - try std.testing.expectEqual(@as(u32, 0), frame.segments[0].index_start); - try std.testing.expectEqual(@as(u32, 12), frame.segments[0].index_count); - try std.testing.expectEqual(@as(u32, 0), frame.segments[0].quad_start); - try std.testing.expectEqual(@as(u32, 2), frame.segments[0].quad_count); - - // Segment 1: the trailing shape. - try std.testing.expectEqual(SegmentKind.shape, frame.segments[1].kind); - try std.testing.expectEqual(@as(u32, 0), frame.segments[1].index_start); - try std.testing.expectEqual(@as(u32, 6), frame.segments[1].index_count); -} - -test "drawPolygon: fans rim points into shape indices" { - _ = consumeFrame(); - setScreenSize(800, 600); - setDesignSize(800, 600); - - // 5 rim points -> 5 shape verts, (5-2)=3 fan triangles -> 9 indices. - const pts = [_]Vector2{ - .{ .x = 10, .y = 10 }, - .{ .x = 30, .y = 10 }, - .{ .x = 40, .y = 30 }, - .{ .x = 25, .y = 45 }, - .{ .x = 10, .y = 30 }, - }; - drawPolygon(&pts, white); - - const frame = consumeFrame(); - try std.testing.expectEqual(@as(usize, 1), frame.segments.len); - try std.testing.expectEqual(SegmentKind.shape, frame.segments[0].kind); - try std.testing.expectEqual(@as(u32, 9), frame.segments[0].index_count); - try std.testing.expectEqual(@as(usize, 5), frame.shape_vertices.len); - try std.testing.expectEqual(@as(usize, 9), frame.shape_indices.len); -} - -test "draw segments: consumeFrame resets the segment list exactly once" { - _ = consumeFrame(); - drawRectangleRec(.{ .x = 0, .y = 0, .width = 10, .height = 10 }, white); - const first = consumeFrame(); - try std.testing.expectEqual(@as(usize, 1), first.segments.len); - - // Next frame starts empty — no leakage. - const second = consumeFrame(); - try std.testing.expectEqual(@as(usize, 0), second.segments.len); - try std.testing.expectEqual(@as(usize, 0), second.shape_indices.len); - try std.testing.expectEqual(@as(usize, 0), second.sprite_indices.len); -} - -// ── HiDPI two-size coordinate-model tests ────────────────────────────── -// Verify the new design/physical aspect-fit: with design == physical and -// 1:1 aspect the fit scale is identity (so NDC math is unchanged from the -// old single-size model); a Retina-style 2x physical surface keeps the -// design mapping (fit scale stays identity when aspect ratios match) so -// content fills the framebuffer rather than the top-left quarter. - -test "state: equal design/physical keeps identity NDC mapping" { - setDesignSize(800, 600); - setScreenSize(800, 600); - // Design space getters report the LOGICAL canvas (resolution-independent). - try std.testing.expectEqual(@as(i32, 800), getScreenWidth()); - try std.testing.expectEqual(@as(i32, 600), getScreenHeight()); - try std.testing.expectEqual(@as(i32, 800), getDesignWidth()); - try std.testing.expectEqual(@as(i32, 600), getDesignHeight()); - // A point at the design-canvas center maps to NDC origin. - _ = consumeFrame(); - drawRectangleRec(.{ .x = 400, .y = 300, .width = 0, .height = 0 }, white); - const frame = consumeFrame(); - // First vertex of the (degenerate) rect is at (400,300) -> NDC (0,0). - try std.testing.expectApproxEqAbs(@as(f32, 0.0), frame.shape_vertices[0].position[0], 1e-5); - try std.testing.expectApproxEqAbs(@as(f32, 0.0), frame.shape_vertices[0].position[1], 1e-5); -} - -test "state: Retina 2x physical surface keeps design mapping (no top-left quarter)" { - // Logical 800x600 game on a 1600x1200 physical Retina framebuffer. - // Aspect ratios match, so fit scale is identity and the design canvas - // fills the whole framebuffer — the design-center still maps to NDC 0, - // and the design corners map to the NDC corners (-1..1), NOT to the - // top-left quarter as the old single-size model would have produced. - setDesignSize(800, 600); - setScreenSize(1600, 1200); - _ = consumeFrame(); - // Design-space corners: top-left (0,0) and bottom-right (800,600). - drawRectangleRec(.{ .x = 0, .y = 0, .width = 800, .height = 600 }, white); - const frame = consumeFrame(); - // Vertex 0 = top-left (0,0) -> NDC (-1, +1); vertex 2 = bottom-right - // (800,600) -> NDC (+1, -1). Full-screen coverage, not a quarter. - try std.testing.expectApproxEqAbs(@as(f32, -1.0), frame.shape_vertices[0].position[0], 1e-5); - try std.testing.expectApproxEqAbs(@as(f32, 1.0), frame.shape_vertices[0].position[1], 1e-5); - try std.testing.expectApproxEqAbs(@as(f32, 1.0), frame.shape_vertices[2].position[0], 1e-5); - try std.testing.expectApproxEqAbs(@as(f32, -1.0), frame.shape_vertices[2].position[1], 1e-5); -} - -test "state: screenToDesign maps physical edges to design edges on HiDPI (#331)" { - // 800x600 design on a 1600x1200 (2x) surface (fit==1, design fills it). - // EDGES — not just the center — must map correctly: the old design-space - // bar formula returned (-400,-300) for the top-left, drifting clicks. - setDesignSize(800, 600); - setScreenSize(1600, 1200); - const tl = screenToDesign(0, 0); - try std.testing.expectApproxEqAbs(@as(f32, 0), tl.x, 1e-3); - try std.testing.expectApproxEqAbs(@as(f32, 0), tl.y, 1e-3); - const br = screenToDesign(1600, 1200); - try std.testing.expectApproxEqAbs(@as(f32, 800), br.x, 1e-3); - try std.testing.expectApproxEqAbs(@as(f32, 600), br.y, 1e-3); - const c = screenToDesign(800, 600); - try std.testing.expectApproxEqAbs(@as(f32, 400), c.x, 1e-3); - try std.testing.expectApproxEqAbs(@as(f32, 300), c.y, 1e-3); -} - -test "state: screenToDesign and designToPhysical round-trip incl. letterbox (#331)" { - setDesignSize(800, 600); - setScreenSize(2000, 1000); // wider -> pillarbox; fit_x != fit_y; screen != design - const samples = [_][2]f32{ .{ 0, 0 }, .{ 2000, 1000 }, .{ 1000, 500 }, .{ 500, 250 }, .{ 1750, 800 } }; - for (samples) |s| { - const d = screenToDesign(s[0], s[1]); - const p = designToPhysical(.{ .x = d.x, .y = d.y }); - try std.testing.expectApproxEqAbs(s[0], p.x, 1e-2); - try std.testing.expectApproxEqAbs(s[1], p.y, 1e-2); - } -} - -// ── PNG decoder tests ────────────────────────────────────────────────── -// Each fixture is a real PNG (produced by zlib + the PNG spec, see the -// generator in PR #293's history) embedded as a byte array so the test -// is self-contained and exercises the full sniff → inflate → unfilter → -// RGBA8 pipeline. - -test "decodePng: 2x2 truecolor+alpha (filter None)" { - // Pixels (row-major): (255,0,0,255) (0,255,0,128) / (0,0,255,255) (255,255,0,64) - const png_rgba_2x2 = [_]u8{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x16, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x08, 0x1b, 0x18, 0x80, 0x34, 0x10, 0x30, 0x38, 0x00, 0x00, 0x42, 0x15, 0x07, 0xba, 0x58, 0x65, 0x3e, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 }; - const img = decodePng(&png_rgba_2x2, std.testing.allocator) orelse return error.DecodeFailed; - defer std.testing.allocator.free(img.pixels); - try std.testing.expectEqual(@as(u32, 2), img.width); - try std.testing.expectEqual(@as(u32, 2), img.height); - const want = [_]u8{ 255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 255, 255, 255, 0, 64 }; - try std.testing.expectEqualSlices(u8, &want, img.pixels); -} - -test "decodePng: 3x1 truecolor RGB with Sub filter" { - // Pixels: (10,20,30) (40,60,80) (200,100,50), all alpha padded to 255. - const png_rgb_sub_3x1 = [_]u8{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x94, 0x82, 0x83, 0xe3, 0x00, 0x00, 0x00, 0x12, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xe4, 0x12, 0x91, 0x93, 0xd3, 0x30, 0x5a, 0xa0, 0xf1, 0x08, 0x00, 0x07, 0x36, 0x02, 0x60, 0x4d, 0x9d, 0x20, 0xcd, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 }; - const img = decodePng(&png_rgb_sub_3x1, std.testing.allocator) orelse return error.DecodeFailed; - defer std.testing.allocator.free(img.pixels); - try std.testing.expectEqual(@as(u32, 3), img.width); - try std.testing.expectEqual(@as(u32, 1), img.height); - const want = [_]u8{ 10, 20, 30, 255, 40, 60, 80, 255, 200, 100, 50, 255 }; - try std.testing.expectEqualSlices(u8, &want, img.pixels); -} - -test "decodePng: 2x2 indexed palette with tRNS alpha" { - // Palette: idx0=red(255,0,0) a=255, idx1=green(0,255,0) a=128. - // Indices row-major: 0,1 / 1,0 - const png_indexed_2x2 = [_]u8{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x03, 0x00, 0x00, 0x00, 0x45, 0x68, 0xfd, 0x16, 0x00, 0x00, 0x00, 0x06, 0x50, 0x4c, 0x54, 0x45, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0xd2, 0x87, 0xef, 0x71, 0x00, 0x00, 0x00, 0x02, 0x74, 0x52, 0x4e, 0x53, 0xff, 0x80, 0x08, 0x0f, 0xb3, 0x6a, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0x60, 0x60, 0x04, 0x42, 0x00, 0x00, 0x0c, 0x00, 0x03, 0x15, 0x9e, 0x18, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 }; - const img = decodePng(&png_indexed_2x2, std.testing.allocator) orelse return error.DecodeFailed; - defer std.testing.allocator.free(img.pixels); - try std.testing.expectEqual(@as(u32, 2), img.width); - try std.testing.expectEqual(@as(u32, 2), img.height); - const want = [_]u8{ 255, 0, 0, 255, 0, 255, 0, 128, 0, 255, 0, 128, 255, 0, 0, 255 }; - try std.testing.expectEqualSlices(u8, &want, img.pixels); -} - -test "decodePng: 1x2 grayscale+alpha with Up filter" { - // Row0 (gray=100, a=255), Row1 (gray=50, a=128); row1 uses Up filter. - const png_gray_alpha_up_1x2 = [_]u8{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x08, 0x04, 0x00, 0x00, 0x00, 0x33, 0x88, 0x7e, 0xac, 0x00, 0x00, 0x00, 0x0e, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0x48, 0xf9, 0xcf, 0x74, 0xae, 0x11, 0x00, 0x08, 0x19, 0x02, 0xb5, 0xd5, 0xbb, 0x84, 0x9c, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 }; - const img = decodePng(&png_gray_alpha_up_1x2, std.testing.allocator) orelse return error.DecodeFailed; - defer std.testing.allocator.free(img.pixels); - try std.testing.expectEqual(@as(u32, 1), img.width); - try std.testing.expectEqual(@as(u32, 2), img.height); - const want = [_]u8{ 100, 100, 100, 255, 50, 50, 50, 128 }; - try std.testing.expectEqualSlices(u8, &want, img.pixels); -} - -test "decodePng: rejects non-PNG and routes through decodeImage" { - const not_png = [_]u8{ 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08 }; - try std.testing.expect(decodePng(¬_png, std.testing.allocator) == null); - - // decodeImage should dispatch a real PNG to the PNG decoder. - const png_rgba_2x2 = [_]u8{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d, 0x24, 0x00, 0x00, 0x00, 0x16, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x08, 0x1b, 0x18, 0x80, 0x34, 0x10, 0x30, 0x38, 0x00, 0x00, 0x42, 0x15, 0x07, 0xba, 0x58, 0x65, 0x3e, 0xfa, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 }; - const img = try decodeImage("", &png_rgba_2x2, std.testing.allocator); - defer std.testing.allocator.free(img.pixels); - try std.testing.expectEqual(@as(u32, 2), img.width); -} - -// ── Glyph-atlas text tests ───────────────────────────────────────────── -// Pure-CPU: drive drawText and inspect the sprite batch / segments / atlas -// pixels. No GPU needed. - -test "drawText: emits one sprite quad per non-space printable glyph" { - _ = consumeFrame(); - setScreenSize(800, 600); - setDesignSize(800, 600); - - const atlas_id = ensureFontAtlas(); - try std.testing.expect(atlas_id != 0); - - drawText("Hi", 10, 10, 16, white); - - const frame = consumeFrame(); - - // "Hi" = 2 non-space printable glyphs -> 2 sprite quads (4 verts / 6 - // indices each), all tagged with the atlas texture id. - try std.testing.expectEqual(@as(usize, 2), frame.sprite_texture_ids.len); - try std.testing.expectEqual(atlas_id, frame.sprite_texture_ids[0]); - try std.testing.expectEqual(atlas_id, frame.sprite_texture_ids[1]); - try std.testing.expectEqual(@as(usize, 8), frame.sprite_vertices.len); - try std.testing.expectEqual(@as(usize, 12), frame.sprite_indices.len); - // Text emits zero shape geometry now. - try std.testing.expectEqual(@as(usize, 0), frame.shape_vertices.len); - - // The two coalesce into a single ordered sprite segment. - try std.testing.expectEqual(@as(usize, 1), frame.segments.len); - try std.testing.expectEqual(SegmentKind.sprite, frame.segments[0].kind); - try std.testing.expectEqual(@as(u32, 12), frame.segments[0].index_count); - try std.testing.expectEqual(@as(u32, 2), frame.segments[0].quad_count); -} - -test "drawText: space emits no quad but advances the cursor" { - _ = consumeFrame(); - setScreenSize(800, 600); - setDesignSize(800, 600); - _ = ensureFontAtlas(); - - // NOTE: consumeFrame returns slices into the SAME global vertex buffer, - // so a later drawText overwrites an earlier frame's slice. Capture the - // few values we need immediately after each consume, before drawing again. - - // "AB" = 2 glyphs; "A B" = 3 chars but the space emits no quad -> still 2. - drawText("AB", 0, 0, 16, white); - const f1 = consumeFrame(); - try std.testing.expectEqual(@as(usize, 2), f1.sprite_texture_ids.len); - // Both strings put glyph 'A' at x=0 (quad 0 TL = sprite_vertices[0]) and - // 'B' at quad 1 TL = sprite_vertices[4]. Gap between them, in NDC. - const a_left_f1 = f1.sprite_vertices[0].position[0]; - const gap_f1 = f1.sprite_vertices[4].position[0] - a_left_f1; - - drawText("A B", 0, 0, 16, white); - const f2 = consumeFrame(); - try std.testing.expectEqual(@as(usize, 2), f2.sprite_texture_ids.len); - const a_left_f2 = f2.sprite_vertices[0].position[0]; - const gap_f2 = f2.sprite_vertices[4].position[0] - a_left_f2; - - // 'A' starts at the same place in both strings. - try std.testing.expectApproxEqAbs(a_left_f1, a_left_f2, 1e-5); - // The space advanced the cursor: 'B' sits one extra glyph_w further - // right in "A B" than in "AB", so the gap is exactly doubled. Ratio is - // screen-size independent. - try std.testing.expect(gap_f1 > 0); - try std.testing.expectApproxEqAbs(2 * gap_f1, gap_f2, 1e-5); -} - -test "buildFontAtlasPixels: coverage alpha set where glyph bit is set, padding transparent" { - var pixels: [font.atlas_w * font.atlas_h * 4]u8 = undefined; - buildFontAtlasPixels(&pixels); - - // 'A' (0x41) glyph row 0 = 0x18 = 0b00011000 -> set bits at columns 3,4. - const gi: usize = 0x41 - 0x20; - const cell_col = gi % font.atlas_cols; - const cell_row = gi / font.atlas_cols; - const ox = cell_col * font.atlas_cell_w + font.atlas_pad; - const oy = cell_row * font.atlas_cell_h + font.atlas_pad; - - // Set texel (row 0, col 3): white RGB + alpha 255. - { - const idx = ((oy + 0) * font.atlas_w + (ox + 3)) * 4; - try std.testing.expectEqual(@as(u8, 255), pixels[idx + 0]); - try std.testing.expectEqual(@as(u8, 255), pixels[idx + 1]); - try std.testing.expectEqual(@as(u8, 255), pixels[idx + 2]); - try std.testing.expectEqual(@as(u8, 255), pixels[idx + 3]); - } - // Clear texel (row 0, col 0): fully transparent. - { - const idx = ((oy + 0) * font.atlas_w + (ox + 0)) * 4; - try std.testing.expectEqual(@as(u8, 0), pixels[idx + 3]); - } - // Padding texel just left of the glyph's inner origin: fully transparent. - { - const idx = (oy * font.atlas_w + (ox - 1)) * 4; - try std.testing.expectEqual(@as(u8, 0), pixels[idx + 0]); - try std.testing.expectEqual(@as(u8, 0), pixels[idx + 3]); - } -} diff --git a/backends/wgpu/src/gfx/astc.zig b/backends/wgpu/src/gfx/astc.zig deleted file mode 100644 index 3c191722..00000000 --- a/backends/wgpu/src/gfx/astc.zig +++ /dev/null @@ -1,128 +0,0 @@ -//! ASTC container parsing (the astcenc `.astc` file format). -//! -//! An `.astc` file is a 16-byte header followed by the raw compressed blocks: -//! bytes 0..3 magic 0x5CA1AB13 (little-endian on disk: 13 ab a1 5c) -//! byte 4 block dim X (e.g. 8) -//! byte 5 block dim Y -//! byte 6 block dim Z (1 for 2D) -//! bytes 7..9 image X size (24-bit little-endian) -//! bytes 10..12 image Y size (24-bit little-endian) -//! bytes 13..15 image Z size (24-bit little-endian) -//! bytes 16.. compressed ASTC blocks (uploaded to the GPU verbatim) -//! -//! This module is pure byte-parsing — NO wgpu/GPU dependency — so the format -//! handling is host-testable. The backend maps `block_x`/`block_y` to a wgpu -//! `TextureFormat` and uploads `blocks` directly (zero CPU decode), skipping -//! the CPU PNG/BMP/TGA decoders entirely. For a 4K atlas this is the -//! zero-cost upload path (see labelle-gfx#269 / #341). This parser is a -//! verbatim port of the bgfx backend's `gfx/astc.zig`. - -const std = @import("std"); - -/// On-disk magic, little-endian bytes. -pub const MAGIC = [4]u8{ 0x13, 0xab, 0xa1, 0x5c }; - -pub const Header = struct { - block_x: u8, - block_y: u8, - block_z: u8, - width: u32, - height: u32, - depth: u32, - /// The compressed block payload (everything after the 16-byte header). - blocks: []const u8, -}; - -/// True if `data` begins with the ASTC magic and is long enough for a header. -pub fn isAstc(data: []const u8) bool { - return data.len >= 16 and std.mem.eql(u8, data[0..4], &MAGIC); -} - -/// Parse an `.astc` blob. Returns null when the data isn't ASTC, is truncated -/// (header present but no/short block payload for the stated dimensions), or -/// has degenerate (zero) dimensions. -pub fn parse(data: []const u8) ?Header { - if (!isAstc(data)) return null; - const bx = data[4]; - const by = data[5]; - const bz = data[6]; - if (bx == 0 or by == 0 or bz == 0) return null; - - const w: u32 = std.mem.readInt(u24, data[7..10], .little); - const h: u32 = std.mem.readInt(u24, data[10..13], .little); - const d: u32 = std.mem.readInt(u24, data[13..16], .little); - if (w == 0 or h == 0 or d == 0) return null; - - // Expected block payload = ceil(w/bx) * ceil(h/by) * ceil(d/bz) * 16 bytes. - const blocks_x = (w + bx - 1) / bx; - const blocks_y = (h + by - 1) / by; - const blocks_z = (d + bz - 1) / bz; - const expected = std.math.mul(usize, std.math.mul(usize, std.math.mul(usize, blocks_x, blocks_y) catch return null, blocks_z) catch return null, 16) catch return null; - if (data.len - 16 < expected) return null; // truncated - - return .{ - .block_x = bx, - .block_y = by, - .block_z = bz, - .width = w, - .height = h, - .depth = d, - .blocks = data[16 .. 16 + expected], - }; -} - -// ── Tests (pure; no bgfx) ─────────────────────────────────────────────────── - -fn makeHeader(buf: *[16]u8, bx: u8, by: u8, w: u24, h: u24) void { - @memcpy(buf[0..4], &MAGIC); - buf[4] = bx; - buf[5] = by; - buf[6] = 1; - std.mem.writeInt(u24, buf[7..10], w, .little); - std.mem.writeInt(u24, buf[10..13], h, .little); - std.mem.writeInt(u24, buf[13..16], 1, .little); -} - -test "isAstc detects the magic" { - var h: [16]u8 = undefined; - makeHeader(&h, 8, 8, 64, 64); - try std.testing.expect(isAstc(&h)); - try std.testing.expect(!isAstc("not an astc file at all")); - try std.testing.expect(!isAstc(&[_]u8{ 0x13, 0xab })); // too short -} - -test "parse reads block + image dims for 8x8" { - // 64x64 @ 8x8 = 8*8 blocks * 16 bytes = 1024 block bytes. - var buf = [_]u8{0} ** (16 + 1024); - makeHeader(buf[0..16], 8, 8, 64, 64); - const hdr = parse(&buf) orelse return error.TestUnexpected; - try std.testing.expectEqual(@as(u8, 8), hdr.block_x); - try std.testing.expectEqual(@as(u8, 8), hdr.block_y); - try std.testing.expectEqual(@as(u32, 64), hdr.width); - try std.testing.expectEqual(@as(u32, 64), hdr.height); - try std.testing.expectEqual(@as(usize, 1024), hdr.blocks.len); -} - -test "parse honors non-multiple dims (ceil to block grid)" { - // 100x100 @ 8x8 => ceil(100/8)=13 blocks each way => 13*13*16 = 2704. - var buf = [_]u8{0} ** (16 + 2704); - makeHeader(buf[0..16], 8, 8, 100, 100); - const hdr = parse(&buf) orelse return error.TestUnexpected; - try std.testing.expectEqual(@as(usize, 2704), hdr.blocks.len); -} - -test "parse rejects truncated block payload" { - // Header says 64x64 @ 8x8 (needs 1024 block bytes) but only 500 provided. - var buf = [_]u8{0} ** (16 + 500); - makeHeader(buf[0..16], 8, 8, 64, 64); - try std.testing.expect(parse(&buf) == null); -} - -test "parse rejects non-astc / degenerate dims" { - try std.testing.expect(parse("totally not astc, no magic here!!") == null); - var buf = [_]u8{0} ** 64; - makeHeader(buf[0..16], 8, 8, 0, 64); // zero width - try std.testing.expect(parse(&buf) == null); - makeHeader(buf[0..16], 0, 8, 64, 64); // zero block dim - try std.testing.expect(parse(&buf) == null); -} diff --git a/backends/wgpu/src/gfx/batch.zig b/backends/wgpu/src/gfx/batch.zig deleted file mode 100644 index 4e0dfa9d..00000000 --- a/backends/wgpu/src/gfx/batch.zig +++ /dev/null @@ -1,293 +0,0 @@ -/// Vertex/index batch state + the ordered draw-segment stream for the -/// WebGPU backend. Owns the shape + sprite vertex/index buffers, the -/// per-quad texture-id table, and the segment list that records -/// shape/sprite submission order. The draw/font submodules append into -/// these buffers; the window submitter drains them once per frame via -/// `consumeFrame`. -const std = @import("std"); -const types = @import("types.zig"); - -const log = std.log.scoped(.wgpu_gfx); - -const ColorVertex = types.ColorVertex; -const SpriteVertex = types.SpriteVertex; - -// ── Shape batch ─────────────────────────────────────────────────────── - -pub const MAX_SHAPE_VERTICES = 16384; -pub const MAX_SHAPE_INDICES = 32768; -pub const MAX_SPRITE_VERTICES = 8192; -pub const MAX_SPRITE_INDICES = 16384; -pub const MAX_SPRITE_QUADS = MAX_SPRITE_VERTICES / 4; - -var shape_vertices: [MAX_SHAPE_VERTICES]ColorVertex = undefined; -var shape_indices: [MAX_SHAPE_INDICES]u32 = undefined; -var shape_vertex_count: usize = 0; -var shape_index_count: usize = 0; - -var sprite_vertices: [MAX_SPRITE_VERTICES]SpriteVertex = undefined; -var sprite_indices: [MAX_SPRITE_INDICES]u32 = undefined; -var sprite_vertex_count: usize = 0; -var sprite_index_count: usize = 0; - -/// Texture ID for each sprite quad, so the renderer knows which texture to bind. -var sprite_texture_ids: [MAX_SPRITE_QUADS]u32 = undefined; -var sprite_quad_count: usize = 0; - -// ── Ordered draw-segment list ────────────────────────────────────────── -// -// Shapes and sprites live in two separate vertex/index buffers (distinct -// vertex formats + pipelines), but a frame must still composite them in -// strict submission order — a game may draw a shape *over* a sprite within -// one frame. We record that order as a list of contiguous same-kind -// segments. Each segment points into the index buffer of its kind (and, -// for sprites, into `sprite_texture_ids`). Consecutive draws of the same -// kind extend the current segment; a kind switch starts a new one. The -// window submitter walks this list in order, switching pipelines per -// segment, so painter's order is preserved with at most one drawIndexed -// per kind-run (plus the existing same-texture coalescing inside a sprite -// segment). - -pub const SegmentKind = enum { shape, sprite }; - -/// One contiguous run of same-kind draws. -/// - `index_start`/`index_count`: offset+length into the relevant kind's -/// index buffer (shape_indices or sprite_indices). -/// - `quad_start`/`quad_count`: offset+length into `sprite_texture_ids`; -/// zero for shape segments. -pub const DrawSegment = struct { - kind: SegmentKind, - index_start: u32, - index_count: u32, - quad_start: u32 = 0, - quad_count: u32 = 0, -}; - -/// A realistic frame has only a handful of shape/sprite kind switches, so a -/// modest cap covers any sane workload. On overflow we fail safe by DROPPING -/// the overflow draw from the segment stream: its geometry was already -/// appended to the (separate) shape/sprite vertex+index buffers, but no -/// segment references it, so it simply isn't drawn. We must NOT fold it into -/// the trailing segment — by the time we reach the overflow check the tail is -/// always the *opposite* kind (a same-kind tail is extended and returns -/// earlier), and shape vs. sprite segments draw from different index buffers, -/// so folding would make the draw over-read the wrong buffer. Only the -/// overflow tail goes unrendered; a warning is logged once per such frame. -const MAX_DRAW_SEGMENTS = 1024; - -var draw_segments: [MAX_DRAW_SEGMENTS]DrawSegment = undefined; -var draw_segment_count: usize = 0; -var draw_segments_overflowed: bool = false; - -/// Record that a shape draw of `n_indices` indices was just appended to the -/// shape index buffer. Extends the trailing shape segment, or opens a new -/// one on a kind switch. Call AFTER the indices have been appended is fine -/// — we derive `index_start` from the pre-append count, which we pass in. -pub fn noteShapeDraw(index_start: u32, n_indices: u32) void { - if (draw_segment_count > 0) { - const last = &draw_segments[draw_segment_count - 1]; - if (last.kind == .shape) { - last.index_count += n_indices; - return; - } - } - if (draw_segment_count >= MAX_DRAW_SEGMENTS) { - // Overflow: drop this draw from the segment stream (see - // MAX_DRAW_SEGMENTS doc). The tail here is always a sprite segment, - // which draws from the sprite index buffer — folding shape indices - // into it would over-read the wrong buffer, so we drop instead. - if (!draw_segments_overflowed) { - log.warn("draw-segment list full ({d}); dropping overflow draws this frame", .{MAX_DRAW_SEGMENTS}); - draw_segments_overflowed = true; - } - return; - } - draw_segments[draw_segment_count] = .{ - .kind = .shape, - .index_start = index_start, - .index_count = n_indices, - }; - draw_segment_count += 1; -} - -/// Record that a sprite quad draw of `n_indices` indices (6) and one quad -/// was just appended. Extends the trailing sprite segment, or opens a new -/// one on a kind switch. -pub fn noteSpriteDraw(index_start: u32, n_indices: u32, quad_start: u32) void { - if (draw_segment_count > 0) { - const last = &draw_segments[draw_segment_count - 1]; - if (last.kind == .sprite) { - last.index_count += n_indices; - last.quad_count += 1; - return; - } - } - if (draw_segment_count >= MAX_DRAW_SEGMENTS) { - // Overflow: drop this draw from the segment stream (see - // MAX_DRAW_SEGMENTS doc). The tail here is always a shape segment, - // which draws from the shape index buffer — folding sprite indices - // into it would over-read the wrong buffer, so we drop instead. - if (!draw_segments_overflowed) { - log.warn("draw-segment list full ({d}); dropping overflow draws this frame", .{MAX_DRAW_SEGMENTS}); - draw_segments_overflowed = true; - } - return; - } - draw_segments[draw_segment_count] = .{ - .kind = .sprite, - .index_start = index_start, - .index_count = n_indices, - .quad_start = quad_start, - .quad_count = 1, - }; - draw_segment_count += 1; -} - -/// Reset the ordered segment list for the next frame. -fn resetSegments() void { - draw_segment_count = 0; - draw_segments_overflowed = false; -} - -// ── Batch accessors (used by the draw/font submodules) ───────────────── - -pub fn shapeVertexCount() usize { - return shape_vertex_count; -} - -pub fn shapeIndexCount() usize { - return shape_index_count; -} - -pub fn spriteVertexCount() usize { - return sprite_vertex_count; -} - -pub fn spriteIndexCount() usize { - return sprite_index_count; -} - -pub fn spriteQuadCount() usize { - return sprite_quad_count; -} - -/// Check whether the shape batch has room for the given number of vertices and indices. -pub fn hasShapeCapacity(verts: usize, idxs: usize) bool { - return (shape_vertex_count + verts <= MAX_SHAPE_VERTICES) and - (shape_index_count + idxs <= MAX_SHAPE_INDICES); -} - -/// Check whether the sprite batch has room for the given number of vertices and indices. -pub fn hasSpriteCapacity(verts: usize, idxs: usize) bool { - return (sprite_vertex_count + verts <= MAX_SPRITE_VERTICES) and - (sprite_index_count + idxs <= MAX_SPRITE_INDICES); -} - -pub fn appendShapeVertex(v: ColorVertex) void { - shape_vertices[shape_vertex_count] = v; - shape_vertex_count += 1; -} - -pub fn appendShapeIndex(idx: u32) void { - shape_indices[shape_index_count] = idx; - shape_index_count += 1; -} - -pub fn appendSpriteVertex(v: SpriteVertex) void { - sprite_vertices[sprite_vertex_count] = v; - sprite_vertex_count += 1; -} - -pub fn appendSpriteIndex(idx: u32) void { - sprite_indices[sprite_index_count] = idx; - sprite_index_count += 1; -} - -/// Record a sprite quad's texture id (one per 4 verts / 6 indices). No-op -/// past the quad cap so the buffer never overruns. -pub fn appendSpriteTextureId(id: u32) void { - if (sprite_quad_count < MAX_SPRITE_QUADS) { - sprite_texture_ids[sprite_quad_count] = id; - sprite_quad_count += 1; - } -} - -/// Reset shape batch for the next frame. -pub fn resetShapeBatch() void { - shape_vertex_count = 0; - shape_index_count = 0; -} - -/// Reset sprite batch for the next frame. -pub fn resetSpriteBatch() void { - sprite_vertex_count = 0; - sprite_index_count = 0; - sprite_quad_count = 0; -} - -/// Consume shape batch data for GPU submission (called once per frame at endDrawing). -/// Resets the batch after returning — the returned slices are valid until the next draw call. -pub fn consumeShapeBatch() struct { vertices: []const ColorVertex, indices: []const u32 } { - const vcount = shape_vertex_count; - const icount = shape_index_count; - resetShapeBatch(); - return .{ - .vertices = shape_vertices[0..vcount], - .indices = shape_indices[0..icount], - }; -} - -/// Consume sprite batch data for GPU submission (called once per frame at endDrawing). -/// Resets the batch after returning — the returned slices are valid until the next draw call. -/// `texture_ids` has one entry per quad (every 4 vertices / 6 indices). -pub fn consumeSpriteBatch() struct { vertices: []const SpriteVertex, indices: []const u32, texture_ids: []const u32 } { - const vcount = sprite_vertex_count; - const icount = sprite_index_count; - const qcount = sprite_quad_count; - resetSpriteBatch(); - return .{ - .vertices = sprite_vertices[0..vcount], - .indices = sprite_indices[0..icount], - .texture_ids = sprite_texture_ids[0..qcount], - }; -} - -/// Unified per-frame snapshot for the GPU submitter: both vertex/index -/// buffers, the per-quad texture ids, and the ordered draw-segment stream -/// that records shape/sprite submission order. Slices are valid until the -/// next draw call. -pub const Frame = struct { - shape_vertices: []const ColorVertex, - shape_indices: []const u32, - sprite_vertices: []const SpriteVertex, - sprite_indices: []const u32, - sprite_texture_ids: []const u32, - segments: []const DrawSegment, -}; - -/// Consume the whole frame at once and reset all batch state — including -/// the segment list — exactly ONCE. This is the path the window submitter -/// uses. `consumeShapeBatch`/`consumeSpriteBatch` remain for standalone -/// tests, but mixing them with `consumeFrame` in the same frame would -/// double-drain the vertex/index buffers, so callers pick one. -pub fn consumeFrame() Frame { - const shape_vcount = shape_vertex_count; - const shape_icount = shape_index_count; - const sprite_vcount = sprite_vertex_count; - const sprite_icount = sprite_index_count; - const qcount = sprite_quad_count; - const seg_count = draw_segment_count; - - resetShapeBatch(); - resetSpriteBatch(); - resetSegments(); - - return .{ - .shape_vertices = shape_vertices[0..shape_vcount], - .shape_indices = shape_indices[0..shape_icount], - .sprite_vertices = sprite_vertices[0..sprite_vcount], - .sprite_indices = sprite_indices[0..sprite_icount], - .sprite_texture_ids = sprite_texture_ids[0..qcount], - .segments = draw_segments[0..seg_count], - }; -} diff --git a/backends/wgpu/src/gfx/draw.zig b/backends/wgpu/src/gfx/draw.zig deleted file mode 100644 index 52fa8324..00000000 --- a/backends/wgpu/src/gfx/draw.zig +++ /dev/null @@ -1,290 +0,0 @@ -/// Shape primitives (rect / circle / line / triangle / polygon) and the -/// textured-quad sprite draw for the WebGPU backend. State-free: positions -/// are mapped to NDC via `state.toNdcX/toNdcY` (which apply the active -/// camera + the HiDPI design→physical aspect-fit) and appended into the -/// shared batch buffers in `batch.zig`. -const std = @import("std"); -const types = @import("types.zig"); -const state = @import("state.zig"); -const batch = @import("batch.zig"); - -const log = std.log.scoped(.wgpu_gfx); - -const Color = types.Color; -const Rectangle = types.Rectangle; -const Vector2 = types.Vector2; -const Texture = types.Texture; -const ColorVertex = types.ColorVertex; -const SpriteVertex = types.SpriteVertex; - -const toNdcX = state.toNdcX; -const toNdcY = state.toNdcY; - -// ── Draw primitives (Backend contract) ───────────────────────────────── - -pub fn drawRectangleRec(rec: Rectangle, tint: Color) void { - if (!batch.hasShapeCapacity(4, 6)) { - log.warn("shape batch full, dropping rectangle primitive", .{}); - return; - } - const col = tint.toAbgr(); - const x = rec.x; - const y = rec.y; - const w = rec.width; - const h = rec.height; - const base: u32 = @intCast(batch.shapeVertexCount()); - const index_start: u32 = @intCast(batch.shapeIndexCount()); - - // 4 vertices for the rectangle - batch.appendShapeVertex(ColorVertex.init(toNdcX(x), toNdcY(y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(x + w), toNdcY(y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(x + w), toNdcY(y + h), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(x), toNdcY(y + h), col)); - - // 2 triangles (CCW winding) - batch.appendShapeIndex(base + 0); - batch.appendShapeIndex(base + 1); - batch.appendShapeIndex(base + 2); - batch.appendShapeIndex(base + 0); - batch.appendShapeIndex(base + 2); - batch.appendShapeIndex(base + 3); - - batch.noteShapeDraw(index_start, 6); -} - -pub fn drawCircle(center_x: f32, center_y: f32, radius: f32, tint: Color) void { - const segments: u32 = 36; - if (!batch.hasShapeCapacity(segments + 2, segments * 3)) { - log.warn("shape batch full, dropping circle primitive", .{}); - return; - } - const col = tint.toAbgr(); - const base: u32 = @intCast(batch.shapeVertexCount()); - const index_start: u32 = @intCast(batch.shapeIndexCount()); - - // Center vertex - batch.appendShapeVertex(ColorVertex.init(toNdcX(center_x), toNdcY(center_y), col)); - - // Perimeter vertices - var i: u32 = 0; - while (i <= segments) : (i += 1) { - const angle = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi; - const px = center_x + @cos(angle) * radius; - const py = center_y + @sin(angle) * radius; - batch.appendShapeVertex(ColorVertex.init(toNdcX(px), toNdcY(py), col)); - } - - // Fan triangles (center + 2 consecutive perimeter vertices) - i = 0; - while (i < segments) : (i += 1) { - batch.appendShapeIndex(base); // center - batch.appendShapeIndex(base + i + 1); - batch.appendShapeIndex(base + i + 2); - } - - batch.noteShapeDraw(index_start, segments * 3); -} - -pub fn drawLine(start_x: f32, start_y: f32, end_x: f32, end_y: f32, thickness: f32, tint: Color) void { - if (!batch.hasShapeCapacity(4, 6)) { - log.warn("shape batch full, dropping line primitive", .{}); - return; - } - const col = tint.toAbgr(); - const dx = end_x - start_x; - const dy = end_y - start_y; - const len = @sqrt(dx * dx + dy * dy); - - if (len < 0.0001) return; // skip degenerate lines - - // Perpendicular offset for thickness - const perp_x = -dy / len * (thickness * 0.5); - const perp_y = dx / len * (thickness * 0.5); - - const base: u32 = @intCast(batch.shapeVertexCount()); - const index_start: u32 = @intCast(batch.shapeIndexCount()); - - // Quad from 4 offset vertices - batch.appendShapeVertex(ColorVertex.init(toNdcX(start_x + perp_x), toNdcY(start_y + perp_y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(start_x - perp_x), toNdcY(start_y - perp_y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(end_x - perp_x), toNdcY(end_y - perp_y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(end_x + perp_x), toNdcY(end_y + perp_y), col)); - - batch.appendShapeIndex(base + 0); - batch.appendShapeIndex(base + 1); - batch.appendShapeIndex(base + 2); - batch.appendShapeIndex(base + 0); - batch.appendShapeIndex(base + 2); - batch.appendShapeIndex(base + 3); - - batch.noteShapeDraw(index_start, 6); -} - -/// Filled triangle through the three absolute vertices `v1`, `v2`, -/// `v3` (design-pixel space — position + scale already applied by the -/// caller). Point/Color signature matches the labelle-gfx Backend -/// contract; the three vertices are batched as one shape triangle. -pub fn drawTriangle(v1: Vector2, v2: Vector2, v3: Vector2, tint: Color) void { - if (!batch.hasShapeCapacity(3, 3)) { - log.warn("shape batch full, dropping triangle primitive", .{}); - return; - } - const col = tint.toAbgr(); - const base: u32 = @intCast(batch.shapeVertexCount()); - const index_start: u32 = @intCast(batch.shapeIndexCount()); - - batch.appendShapeVertex(ColorVertex.init(toNdcX(v1.x), toNdcY(v1.y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(v2.x), toNdcY(v2.y), col)); - batch.appendShapeVertex(ColorVertex.init(toNdcX(v3.x), toNdcY(v3.y), col)); - - batch.appendShapeIndex(base + 0); - batch.appendShapeIndex(base + 1); - batch.appendShapeIndex(base + 2); - - batch.noteShapeDraw(index_start, 3); -} - -/// Filled convex polygon through the absolute rim vertices in `points` -/// (design-pixel space — centre + scale already applied by the caller). -/// Slice/Color signature matches the labelle-gfx Backend contract; the -/// rim is batched as a triangle fan anchored at `points[0]`. -/// Max rim points a single polygon may carry. Guards the u32 index math -/// below from overflow (a count this large could never fit the shape batch -/// anyway); the gfx renderer already clamps polygon/arc tessellation to 128. -pub const max_polygon_points: usize = 256; - -pub fn drawPolygon(points: []const Vector2, tint: Color) void { - if (points.len < 3) return; - if (points.len > max_polygon_points) { - log.warn("polygon has {d} rim points (> max {d}), dropping", .{ points.len, max_polygon_points }); - return; - } - const num_verts: u32 = @intCast(points.len); - const num_triangles: u32 = num_verts - 2; - const num_indices: u32 = num_triangles * 3; - if (!batch.hasShapeCapacity(num_verts, num_indices)) { - log.warn("shape batch full, dropping polygon primitive", .{}); - return; - } - const col = tint.toAbgr(); - const base: u32 = @intCast(batch.shapeVertexCount()); - const index_start: u32 = @intCast(batch.shapeIndexCount()); - - for (points) |p| { - batch.appendShapeVertex(ColorVertex.init(toNdcX(p.x), toNdcY(p.y), col)); - } - - // Fan triangles: (points[0], points[i+1], points[i+2]). - var i: u32 = 0; - while (i < num_triangles) : (i += 1) { - batch.appendShapeIndex(base); - batch.appendShapeIndex(base + i + 1); - batch.appendShapeIndex(base + i + 2); - } - - batch.noteShapeDraw(index_start, num_indices); -} - -pub fn drawPoly(center_x: f32, center_y: f32, sides: i32, radius: f32, rotation: f32, tint: Color) void { - if (sides < 3 or radius <= 0) return; - const num_sides: u32 = @intCast(sides); - if (!batch.hasShapeCapacity(num_sides + 2, num_sides * 3)) { - log.warn("shape batch full, dropping polygon primitive", .{}); - return; - } - const col = tint.toAbgr(); - const base: u32 = @intCast(batch.shapeVertexCount()); - const index_start: u32 = @intCast(batch.shapeIndexCount()); - - // Convert rotation from degrees to radians (consistent with drawTexturePro / raylib convention) - const rot_rad = rotation * std.math.pi / 180.0; - - // Center vertex - batch.appendShapeVertex(ColorVertex.init(toNdcX(center_x), toNdcY(center_y), col)); - - // Perimeter vertices - var i: u32 = 0; - while (i <= num_sides) : (i += 1) { - const angle = rot_rad + (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(num_sides))) * 2.0 * std.math.pi; - const px = center_x + @cos(angle) * radius; - const py = center_y + @sin(angle) * radius; - batch.appendShapeVertex(ColorVertex.init(toNdcX(px), toNdcY(py), col)); - } - - // Fan triangles - i = 0; - while (i < num_sides) : (i += 1) { - batch.appendShapeIndex(base); - batch.appendShapeIndex(base + i + 1); - batch.appendShapeIndex(base + i + 2); - } - - batch.noteShapeDraw(index_start, num_sides * 3); -} - -// ── Texture / Sprite rendering ───────────────────────────────────────── - -pub fn drawTexturePro(texture: Texture, source: Rectangle, dest: Rectangle, origin: Vector2, rotation: f32, tint: Color) void { - if (!batch.hasSpriteCapacity(4, 6)) { - log.warn("sprite batch full, dropping sprite primitive", .{}); - return; - } - const col = tint.toAbgr(); - - // Capture the pre-append offsets for the ordered segment record. - const seg_index_start: u32 = @intCast(batch.spriteIndexCount()); - const seg_quad_start: u32 = @intCast(batch.spriteQuadCount()); - - // Track which texture this quad uses so the renderer can bind correctly. - batch.appendSpriteTextureId(texture.id); - - // UV coordinates from source rectangle - const tex_w: f32 = @floatFromInt(texture.width); - const tex_h: f32 = @floatFromInt(texture.height); - const uv_x0 = source.x / tex_w; - const uv_y0 = source.y / tex_h; - const uv_x1 = (source.x + source.width) / tex_w; - const uv_y1 = (source.y + source.height) / tex_h; - - // Local corner positions relative to origin - const x0 = -origin.x; - const y0 = -origin.y; - const x1 = dest.width - origin.x; - const y1 = dest.height - origin.y; - - // Rotation - const cos_r = @cos(rotation * std.math.pi / 180.0); - const sin_r = @sin(rotation * std.math.pi / 180.0); - - const base: u32 = @intCast(batch.spriteVertexCount()); - - // Top-left - const tx0 = dest.x + (x0 * cos_r - y0 * sin_r); - const ty0 = dest.y + (x0 * sin_r + y0 * cos_r); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(tx0), toNdcY(ty0), uv_x0, uv_y0, col)); - - // Top-right - const tx1 = dest.x + (x1 * cos_r - y0 * sin_r); - const ty1 = dest.y + (x1 * sin_r + y0 * cos_r); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(tx1), toNdcY(ty1), uv_x1, uv_y0, col)); - - // Bottom-right - const tx2 = dest.x + (x1 * cos_r - y1 * sin_r); - const ty2 = dest.y + (x1 * sin_r + y1 * cos_r); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(tx2), toNdcY(ty2), uv_x1, uv_y1, col)); - - // Bottom-left - const tx3 = dest.x + (x0 * cos_r - y1 * sin_r); - const ty3 = dest.y + (x0 * sin_r + y1 * cos_r); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(tx3), toNdcY(ty3), uv_x0, uv_y1, col)); - - // 2 triangles (CCW) - batch.appendSpriteIndex(base + 0); - batch.appendSpriteIndex(base + 1); - batch.appendSpriteIndex(base + 2); - batch.appendSpriteIndex(base + 0); - batch.appendSpriteIndex(base + 2); - batch.appendSpriteIndex(base + 3); - - batch.noteSpriteDraw(seg_index_start, 6, seg_quad_start); -} diff --git a/backends/wgpu/src/gfx/font.zig b/backends/wgpu/src/gfx/font.zig deleted file mode 100644 index ef613429..00000000 --- a/backends/wgpu/src/gfx/font.zig +++ /dev/null @@ -1,282 +0,0 @@ -/// Embedded 8x8 bitmap font + glyph-atlas text rendering for the WebGPU -/// backend. Text renders through the textured-sprite path: the font is -/// baked ONCE into an RGBA8 atlas texture (via `texture.uploadTexture`), -/// and each printable glyph is drawn as a single sampled sprite quad -/// appended to the shared sprite batch in `batch.zig`. Positions map to -/// NDC via `state.toNdcX/toNdcY` (camera + HiDPI aspect-fit aware). -const std = @import("std"); - -const log = std.log.scoped(.wgpu_gfx); - -const types = @import("types.zig"); -const state = @import("state.zig"); -const batch = @import("batch.zig"); -const texture = @import("texture.zig"); - -const Color = types.Color; -const SpriteVertex = types.SpriteVertex; - -const toNdcX = state.toNdcX; -const toNdcY = state.toNdcY; - -// ── Text rendering (bitmap font atlas) ───────────────────────────────── - -/// Minimal 8x8 bitmap font for basic text rendering. -/// Each character is an 8x8 monospaced glyph stored as 8 bytes (1 bit per pixel, MSB-left). -/// Printable ASCII range: 0x20 (' ') through 0x7E ('~'). -const FONT_GLYPH_W = 8; -const FONT_GLYPH_H = 8; - -// Embedded 8x8 font data for printable ASCII (space through '~', 95 glyphs). -// Each glyph is 8 rows of 8 bits packed into u8. -const font_data = initFontData(); - -fn initFontData() [95][8]u8 { - // Minimal embedded bitmap font (subset — uppercase letters, digits, punctuation). - // Unset glyphs render as hollow rectangles. - var data: [95][8]u8 = [_][8]u8{.{ 0, 0, 0, 0, 0, 0, 0, 0 }} ** 95; - - // Space (0x20) — blank - // '!' (0x21) - data[0x21 - 0x20] = .{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, 0x18, 0x00 }; - // '0' - '9' - data[0x30 - 0x20] = .{ 0x3C, 0x66, 0x6E, 0x7E, 0x76, 0x66, 0x3C, 0x00 }; // 0 - data[0x31 - 0x20] = .{ 0x18, 0x38, 0x18, 0x18, 0x18, 0x18, 0x7E, 0x00 }; // 1 - data[0x32 - 0x20] = .{ 0x3C, 0x66, 0x06, 0x0C, 0x18, 0x30, 0x7E, 0x00 }; // 2 - data[0x33 - 0x20] = .{ 0x3C, 0x66, 0x06, 0x1C, 0x06, 0x66, 0x3C, 0x00 }; // 3 - data[0x34 - 0x20] = .{ 0x0C, 0x1C, 0x3C, 0x6C, 0x7E, 0x0C, 0x0C, 0x00 }; // 4 - data[0x35 - 0x20] = .{ 0x7E, 0x60, 0x7C, 0x06, 0x06, 0x66, 0x3C, 0x00 }; // 5 - data[0x36 - 0x20] = .{ 0x1C, 0x30, 0x60, 0x7C, 0x66, 0x66, 0x3C, 0x00 }; // 6 - data[0x37 - 0x20] = .{ 0x7E, 0x06, 0x0C, 0x18, 0x18, 0x18, 0x18, 0x00 }; // 7 - data[0x38 - 0x20] = .{ 0x3C, 0x66, 0x66, 0x3C, 0x66, 0x66, 0x3C, 0x00 }; // 8 - data[0x39 - 0x20] = .{ 0x3C, 0x66, 0x66, 0x3E, 0x06, 0x0C, 0x38, 0x00 }; // 9 - // A-Z - data[0x41 - 0x20] = .{ 0x18, 0x3C, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x00 }; // A - data[0x42 - 0x20] = .{ 0x7C, 0x66, 0x66, 0x7C, 0x66, 0x66, 0x7C, 0x00 }; // B - data[0x43 - 0x20] = .{ 0x3C, 0x66, 0x60, 0x60, 0x60, 0x66, 0x3C, 0x00 }; // C - data[0x44 - 0x20] = .{ 0x78, 0x6C, 0x66, 0x66, 0x66, 0x6C, 0x78, 0x00 }; // D - data[0x45 - 0x20] = .{ 0x7E, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x7E, 0x00 }; // E - data[0x46 - 0x20] = .{ 0x7E, 0x60, 0x60, 0x7C, 0x60, 0x60, 0x60, 0x00 }; // F - data[0x47 - 0x20] = .{ 0x3C, 0x66, 0x60, 0x6E, 0x66, 0x66, 0x3E, 0x00 }; // G - data[0x48 - 0x20] = .{ 0x66, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x66, 0x00 }; // H - data[0x49 - 0x20] = .{ 0x3C, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00 }; // I - data[0x4A - 0x20] = .{ 0x06, 0x06, 0x06, 0x06, 0x06, 0x66, 0x3C, 0x00 }; // J - data[0x4B - 0x20] = .{ 0x66, 0x6C, 0x78, 0x70, 0x78, 0x6C, 0x66, 0x00 }; // K - data[0x4C - 0x20] = .{ 0x60, 0x60, 0x60, 0x60, 0x60, 0x60, 0x7E, 0x00 }; // L - data[0x4D - 0x20] = .{ 0x63, 0x77, 0x7F, 0x6B, 0x63, 0x63, 0x63, 0x00 }; // M - data[0x4E - 0x20] = .{ 0x66, 0x76, 0x7E, 0x7E, 0x6E, 0x66, 0x66, 0x00 }; // N - data[0x4F - 0x20] = .{ 0x3C, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00 }; // O - data[0x50 - 0x20] = .{ 0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60, 0x60, 0x00 }; // P - data[0x51 - 0x20] = .{ 0x3C, 0x66, 0x66, 0x66, 0x6A, 0x6C, 0x36, 0x00 }; // Q - data[0x52 - 0x20] = .{ 0x7C, 0x66, 0x66, 0x7C, 0x6C, 0x66, 0x66, 0x00 }; // R - data[0x53 - 0x20] = .{ 0x3C, 0x66, 0x60, 0x3C, 0x06, 0x66, 0x3C, 0x00 }; // S - data[0x54 - 0x20] = .{ 0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00 }; // T - data[0x55 - 0x20] = .{ 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x00 }; // U - data[0x56 - 0x20] = .{ 0x66, 0x66, 0x66, 0x66, 0x66, 0x3C, 0x18, 0x00 }; // V - data[0x57 - 0x20] = .{ 0x63, 0x63, 0x63, 0x6B, 0x7F, 0x77, 0x63, 0x00 }; // W - data[0x58 - 0x20] = .{ 0x66, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x66, 0x00 }; // X - data[0x59 - 0x20] = .{ 0x66, 0x66, 0x66, 0x3C, 0x18, 0x18, 0x18, 0x00 }; // Y - data[0x5A - 0x20] = .{ 0x7E, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x7E, 0x00 }; // Z - // a-z (lowercase) - data[0x61 - 0x20] = .{ 0x00, 0x00, 0x3C, 0x06, 0x3E, 0x66, 0x3E, 0x00 }; // a - data[0x62 - 0x20] = .{ 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x7C, 0x00 }; // b - data[0x63 - 0x20] = .{ 0x00, 0x00, 0x3C, 0x66, 0x60, 0x66, 0x3C, 0x00 }; // c - data[0x64 - 0x20] = .{ 0x06, 0x06, 0x3E, 0x66, 0x66, 0x66, 0x3E, 0x00 }; // d - data[0x65 - 0x20] = .{ 0x00, 0x00, 0x3C, 0x66, 0x7E, 0x60, 0x3C, 0x00 }; // e - data[0x66 - 0x20] = .{ 0x1C, 0x30, 0x30, 0x7C, 0x30, 0x30, 0x30, 0x00 }; // f - data[0x67 - 0x20] = .{ 0x00, 0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x3C }; // g - data[0x68 - 0x20] = .{ 0x60, 0x60, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x00 }; // h - data[0x69 - 0x20] = .{ 0x18, 0x00, 0x38, 0x18, 0x18, 0x18, 0x3C, 0x00 }; // i - data[0x6A - 0x20] = .{ 0x0C, 0x00, 0x1C, 0x0C, 0x0C, 0x0C, 0x6C, 0x38 }; // j - data[0x6B - 0x20] = .{ 0x60, 0x60, 0x66, 0x6C, 0x78, 0x6C, 0x66, 0x00 }; // k - data[0x6C - 0x20] = .{ 0x38, 0x18, 0x18, 0x18, 0x18, 0x18, 0x3C, 0x00 }; // l - data[0x6D - 0x20] = .{ 0x00, 0x00, 0x76, 0x7F, 0x6B, 0x63, 0x63, 0x00 }; // m - data[0x6E - 0x20] = .{ 0x00, 0x00, 0x7C, 0x66, 0x66, 0x66, 0x66, 0x00 }; // n - data[0x6F - 0x20] = .{ 0x00, 0x00, 0x3C, 0x66, 0x66, 0x66, 0x3C, 0x00 }; // o - data[0x70 - 0x20] = .{ 0x00, 0x00, 0x7C, 0x66, 0x66, 0x7C, 0x60, 0x60 }; // p - data[0x71 - 0x20] = .{ 0x00, 0x00, 0x3E, 0x66, 0x66, 0x3E, 0x06, 0x06 }; // q - data[0x72 - 0x20] = .{ 0x00, 0x00, 0x6C, 0x76, 0x60, 0x60, 0x60, 0x00 }; // r - data[0x73 - 0x20] = .{ 0x00, 0x00, 0x3E, 0x60, 0x3C, 0x06, 0x7C, 0x00 }; // s - data[0x74 - 0x20] = .{ 0x30, 0x30, 0x7C, 0x30, 0x30, 0x30, 0x1C, 0x00 }; // t - data[0x75 - 0x20] = .{ 0x00, 0x00, 0x66, 0x66, 0x66, 0x66, 0x3E, 0x00 }; // u - data[0x76 - 0x20] = .{ 0x00, 0x00, 0x66, 0x66, 0x66, 0x3C, 0x18, 0x00 }; // v - data[0x77 - 0x20] = .{ 0x00, 0x00, 0x63, 0x6B, 0x7F, 0x7F, 0x36, 0x00 }; // w - data[0x78 - 0x20] = .{ 0x00, 0x00, 0x66, 0x3C, 0x18, 0x3C, 0x66, 0x00 }; // x - data[0x79 - 0x20] = .{ 0x00, 0x00, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x3C }; // y - data[0x7A - 0x20] = .{ 0x00, 0x00, 0x7E, 0x0C, 0x18, 0x30, 0x7E, 0x00 }; // z - // Common punctuation - data[0x2E - 0x20] = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00 }; // . - data[0x2C - 0x20] = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x30 }; // , - data[0x3A - 0x20] = .{ 0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x00 }; // : - data[0x3B - 0x20] = .{ 0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x30 }; // ; - data[0x2D - 0x20] = .{ 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00, 0x00 }; // - - data[0x3D - 0x20] = .{ 0x00, 0x00, 0x7E, 0x00, 0x7E, 0x00, 0x00, 0x00 }; // = - data[0x28 - 0x20] = .{ 0x0C, 0x18, 0x30, 0x30, 0x30, 0x18, 0x0C, 0x00 }; // ( - data[0x29 - 0x20] = .{ 0x30, 0x18, 0x0C, 0x0C, 0x0C, 0x18, 0x30, 0x00 }; // ) - data[0x5B - 0x20] = .{ 0x3C, 0x30, 0x30, 0x30, 0x30, 0x30, 0x3C, 0x00 }; // [ - data[0x5D - 0x20] = .{ 0x3C, 0x0C, 0x0C, 0x0C, 0x0C, 0x0C, 0x3C, 0x00 }; // ] - data[0x2F - 0x20] = .{ 0x02, 0x06, 0x0C, 0x18, 0x30, 0x60, 0x40, 0x00 }; // / - data[0x3F - 0x20] = .{ 0x3C, 0x66, 0x06, 0x0C, 0x18, 0x00, 0x18, 0x00 }; // ? - - return data; -} - -// ── Glyph atlas ──────────────────────────────────────────────────────── -// -// Text renders through the textured-sprite path: the embedded 8x8 bitmap -// font is baked ONCE into an RGBA8 atlas texture, and each printable glyph -// is drawn as a single sampled sprite quad (vs. the old per-pixel solid -// shape-quad rasterizer). The atlas lays the 95 printable glyphs in a -// FONT_ATLAS_COLS x FONT_ATLAS_ROWS grid; each cell carries the 8x8 glyph -// plus FONT_ATLAS_PAD px of transparent padding on every side so nearest -// sampling at a quad edge can never bleed into a neighbouring cell. -// -// Per-texel encoding: white RGB (255,255,255) with alpha = coverage (255 -// where the font bit is set, 0 where clear); padding texels are fully -// transparent (0,0,0,0). The sprite fragment shader computes `texel * -// vColor`, so a quad tinted by `tint` yields (tint.rgb, tint.a * coverage) -// — correctly tinted text with crisp edges under the nearest sampler. -const FONT_ATLAS_COLS = 16; -const FONT_ATLAS_ROWS = 6; // 16*6 = 96 cells >= 95 glyphs -const FONT_ATLAS_PAD = 1; // transparent border px around each glyph cell -const FONT_ATLAS_CELL_W = FONT_GLYPH_W + 2 * FONT_ATLAS_PAD; // 10 -const FONT_ATLAS_CELL_H = FONT_GLYPH_H + 2 * FONT_ATLAS_PAD; // 10 -const FONT_ATLAS_W = FONT_ATLAS_COLS * FONT_ATLAS_CELL_W; // 160 -const FONT_ATLAS_H = FONT_ATLAS_ROWS * FONT_ATLAS_CELL_H; // 60 - -/// Texture id of the baked glyph atlas, or 0 = "not built yet". Texture id -/// 0 is never handed out (`next_texture_id` starts at 1), so it's a safe -/// sentinel. Built lazily on the first `drawText`. -var font_atlas_texture_id: u32 = 0; - -/// Render the embedded bitmap font into an RGBA8 pixel buffer laid out as -/// the glyph atlas described above. White-RGB + alpha-coverage encoding; -/// padding texels are transparent. -pub fn buildFontAtlasPixels(buf: *[FONT_ATLAS_W * FONT_ATLAS_H * 4]u8) void { - // Start fully transparent — this also covers all padding texels. - @memset(buf, 0); - for (font_data, 0..) |glyph, gi| { - const cell_col = gi % FONT_ATLAS_COLS; - const cell_row = gi / FONT_ATLAS_COLS; - const origin_x = cell_col * FONT_ATLAS_CELL_W + FONT_ATLAS_PAD; - const origin_y = cell_row * FONT_ATLAS_CELL_H + FONT_ATLAS_PAD; - for (glyph, 0..) |row_bits, row| { - var c: usize = 0; - while (c < FONT_GLYPH_W) : (c += 1) { - const set = (row_bits >> @intCast(FONT_GLYPH_W - 1 - c)) & 1 == 1; - if (!set) continue; // leave transparent - const px = origin_x + c; - const py = origin_y + row; - const idx = (py * FONT_ATLAS_W + px) * 4; - buf[idx + 0] = 255; // R - buf[idx + 1] = 255; // G - buf[idx + 2] = 255; // B - buf[idx + 3] = 255; // A = coverage - } - } - } -} - -/// Latched once the atlas upload fails (e.g. texture-pool exhaustion) so -/// `drawText` doesn't re-bake the 38 KB atlas and spam the log every call / -/// every frame thereafter. -var font_atlas_failed: bool = false; - -/// Build + upload the glyph atlas texture if it hasn't been built yet. -/// Returns the texture id, or 0 on failure (upload error). The failure is -/// latched so subsequent calls return 0 immediately without re-baking. -pub fn ensureFontAtlas() u32 { - if (font_atlas_texture_id != 0) return font_atlas_texture_id; - if (font_atlas_failed) return 0; - var pixels: [FONT_ATLAS_W * FONT_ATLAS_H * 4]u8 = undefined; - buildFontAtlasPixels(&pixels); - // uploadTexture COPIES the pixels into a slot it owns, so a stack - // buffer that dies at return is fine. - const tex = texture.uploadTexture(.{ - .pixels = pixels[0..], - .width = FONT_ATLAS_W, - .height = FONT_ATLAS_H, - }) catch { - log.warn("failed to upload glyph atlas; text will not render", .{}); - font_atlas_failed = true; - return 0; - }; - font_atlas_texture_id = tex.id; - return font_atlas_texture_id; -} - -pub fn drawText(text: [:0]const u8, x: f32, y: f32, size: f32, tint: Color) void { - const atlas_id = ensureFontAtlas(); - if (atlas_id == 0) return; // atlas build failed; nothing to draw - - const col = tint.toAbgr(); - const scale = size / @as(f32, FONT_GLYPH_H); - const glyph_w: f32 = @as(f32, FONT_GLYPH_W) * scale; - - const atlas_w_f: f32 = @as(f32, FONT_ATLAS_W); - const atlas_h_f: f32 = @as(f32, FONT_ATLAS_H); - - var cursor_x = x; - for (text) |ch| { - if (ch == 0) break; // NUL terminator - // Printable, non-space glyphs emit one textured quad. Space (0x20) - // and out-of-range chars emit nothing but still advance the cursor, - // keeping metrics identical to the old rasterizer (width == n_chars - // * glyph_w). - if (ch > 0x20 and ch <= 0x7E) { - if (!batch.hasSpriteCapacity(4, 6)) { - log.warn("sprite batch full, dropping text glyphs", .{}); - return; - } - - const gi: usize = ch - 0x20; - const cell_col = gi % FONT_ATLAS_COLS; - const cell_row = gi / FONT_ATLAS_COLS; - // Inner 8x8 region (padding excluded) — UVs map exactly to it, - // so nearest sampling never picks a padding/neighbour texel. - const inner_x: f32 = @floatFromInt(cell_col * FONT_ATLAS_CELL_W + FONT_ATLAS_PAD); - const inner_y: f32 = @floatFromInt(cell_row * FONT_ATLAS_CELL_H + FONT_ATLAS_PAD); - const uv_x0 = inner_x / atlas_w_f; - const uv_y0 = inner_y / atlas_h_f; - const uv_x1 = (inner_x + @as(f32, FONT_GLYPH_W)) / atlas_w_f; - const uv_y1 = (inner_y + @as(f32, FONT_GLYPH_H)) / atlas_h_f; - - // Screen rect for the whole glyph cell (same metrics as before). - const gx0 = cursor_x; - const gy0 = y; - const gx1 = cursor_x + glyph_w; - const gy1 = y + size; - - const seg_index_start: u32 = @intCast(batch.spriteIndexCount()); - const seg_quad_start: u32 = @intCast(batch.spriteQuadCount()); - - batch.appendSpriteTextureId(atlas_id); - - const base: u32 = @intCast(batch.spriteVertexCount()); - // TL, TR, BR, BL — matches drawTexturePro winding/index pattern. - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(gx0), toNdcY(gy0), uv_x0, uv_y0, col)); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(gx1), toNdcY(gy0), uv_x1, uv_y0, col)); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(gx1), toNdcY(gy1), uv_x1, uv_y1, col)); - batch.appendSpriteVertex(SpriteVertex.init(toNdcX(gx0), toNdcY(gy1), uv_x0, uv_y1, col)); - - batch.appendSpriteIndex(base + 0); - batch.appendSpriteIndex(base + 1); - batch.appendSpriteIndex(base + 2); - batch.appendSpriteIndex(base + 0); - batch.appendSpriteIndex(base + 2); - batch.appendSpriteIndex(base + 3); - - batch.noteSpriteDraw(seg_index_start, 6, seg_quad_start); - } - cursor_x += glyph_w; - } -} - -// ── Atlas-dimension accessors (for tests) ────────────────────────────── - -pub const atlas_w = FONT_ATLAS_W; -pub const atlas_h = FONT_ATLAS_H; -pub const atlas_cols = FONT_ATLAS_COLS; -pub const atlas_cell_w = FONT_ATLAS_CELL_W; -pub const atlas_cell_h = FONT_ATLAS_CELL_H; -pub const atlas_pad = FONT_ATLAS_PAD; diff --git a/backends/wgpu/src/gfx/state.zig b/backends/wgpu/src/gfx/state.zig deleted file mode 100644 index da3ca3c4..00000000 --- a/backends/wgpu/src/gfx/state.zig +++ /dev/null @@ -1,211 +0,0 @@ -/// Screen + camera state for the WebGPU backend, plus the coordinate -/// helpers (`transformX`, `transformY`, `toNdcX`, `toNdcY`) every draw -/// primitive needs. Owns the mutable globals so the draw/font submodules -/// can stay state-free. -/// -/// This is the HiDPI/Retina two-size coordinate model, ported from the -/// bgfx backend (`backends/bgfx/src/gfx/state.zig`, v0.42.0). The wgpu -/// backend previously had a single-size model (`toNdc` mapped directly -/// against `screen_w`, `setDesignSize` was a no-op), which shoved content -/// into the top-left quarter on a Retina surface once the framebuffer was -/// rendered at physical pixels. The two-size model fixes that: -/// -/// - `screen_w/h` — PHYSICAL framebuffer size (the real GPU surface). -/// - `design_w/h` — LOGICAL canvas the game authors in (project width/ -/// height). NDC is computed against THIS, then aspect- -/// fit into the physical framebuffer, so an 800x600 -/// game renders correctly (letterboxed) on any surface. -const types = @import("types.zig"); - -const Vector2 = types.Vector2; -const Camera2D = types.Camera2D; - -// ── State ────────────────────────────────────────────────────────────── - -// Physical framebuffer size (the real surface — desktop GLFW framebuffer). -// Set by window.zig via `setScreenSize` at init + per-frame `ensureSurface`. -var screen_w: i32 = 800; -var screen_h: i32 = 600; -// Design (logical) canvas the game authors in (project width/height). Set -// by window.zig via `setDesignSize`. NDC is computed against THIS, then -// aspect-fit into the physical framebuffer — so the game renders correctly -// (letterboxed) on any device surface, not just one that happens to equal -// the design size. Mirrors the bgfx/sokol backends' state.zig. -var design_w: i32 = 800; -var design_h: i32 = 600; -// Aspect-preserving design→physical fit, recomputed on any size change. -var fit_scale_x: f32 = 1.0; -var fit_scale_y: f32 = 1.0; -var active_camera: ?Camera2D = null; - -fn recomputeFitScale() void { - const sw: f32 = @floatFromInt(screen_w); - const sh: f32 = @floatFromInt(screen_h); - const dw: f32 = @floatFromInt(design_w); - const dh: f32 = @floatFromInt(design_h); - if (sw <= 0 or sh <= 0 or dw <= 0 or dh <= 0) { - fit_scale_x = 1.0; - fit_scale_y = 1.0; - return; - } - const s = @min(sw / dw, sh / dh); - fit_scale_x = s * dw / sw; - fit_scale_y = s * dh / sh; -} - -/// Physical framebuffer size (real surface). Recomputes the fit scale. -pub fn setScreenSize(w: i32, h: i32) void { - screen_w = @max(1, w); - screen_h = @max(1, h); - recomputeFitScale(); -} - -/// Convert a physical-pixel screen coordinate (a GLFW mouse event in -/// framebuffer pixels) to a design-pixel coordinate inside the -/// pillarboxed/letterboxed canvas. -/// -/// Input events arrive in raw framebuffer pixels (the wgpu `input` backend -/// scales GLFW's logical cursor by the framebuffer/window ratio), but -/// game-level math (`cam.screenToWorld`, sprite positions) works in design -/// pixels. The camera's `framebufferToWorld` calls this (guarded by -/// `@hasDecl`) so clicks land correctly on HiDPI/Retina; without it the -/// camera treats framebuffer pixels as design pixels and is off by the -/// pillarbox bars + the design→physical scale. Mirrors the bgfx/sokol -/// backends. -pub fn screenToDesign(px: f32, py: f32) Vector2 { - const sw: f32 = @floatFromInt(screen_w); - const sh: f32 = @floatFromInt(screen_h); - const dw: f32 = @floatFromInt(design_w); - const dh: f32 = @floatFromInt(design_h); - if (sw <= 0 or sh <= 0 or dw <= 0 or dh <= 0) { - return .{ .x = px, .y = py }; - } - // Exact inverse of toNdc: physical framebuffer px → NDC (full- - // framebuffer viewport) → design. The fitted content spans NDC - // [-fit,+fit] = fit_scale*screen_w physical pixels (NOT design_w*fit), - // so the inverse must go through NDC, not a design-space bar. (#331: - // the old design-space bar was wrong whenever screen != design — i.e. - // on HiDPI/Retina — clicks drifted toward the edges.) - const ndc_x = (px / sw) * 2.0 - 1.0; - const ndc_y = 1.0 - (py / sh) * 2.0; - return .{ - .x = ((ndc_x / fit_scale_x) + 1.0) * 0.5 * dw, - .y = (1.0 - ndc_y / fit_scale_y) * 0.5 * dh, - }; -} - -/// Inverse of `screenToDesign`: design-pixel → physical-pixel inside the -/// fitted canvas. Kept for parity with the bgfx/sokol backends. -pub fn designToPhysical(pos: Vector2) Vector2 { - const sw: f32 = @floatFromInt(screen_w); - const sh: f32 = @floatFromInt(screen_h); - const dw: f32 = @floatFromInt(design_w); - const dh: f32 = @floatFromInt(design_h); - if (sw <= 0 or sh <= 0 or dw <= 0 or dh <= 0) { - return pos; - } - // Forward of toNdc: design → NDC → physical framebuffer px. Exact - // inverse of screenToDesign (#331). - const ndc_x = ((pos.x / dw) * 2.0 - 1.0) * fit_scale_x; - const ndc_y = (1.0 - (pos.y / dh) * 2.0) * fit_scale_y; - return .{ - .x = (ndc_x + 1.0) * 0.5 * sw, - .y = (1.0 - ndc_y) * 0.5 * sh, - }; -} - -// ── Camera coordinate transform ──────────────────────────────────────── - -pub fn transformX(x: f32) f32 { - if (active_camera) |cam| { - return (x - cam.target.x) * cam.zoom + cam.offset.x; - } - return x; -} - -pub fn transformY(y: f32) f32 { - if (active_camera) |cam| { - return (y - cam.target.y) * cam.zoom + cam.offset.y; - } - return y; -} - -/// Convert a design-pixel X to NDC, applying the active camera transform, -/// then the aspect-fit so the design canvas letterboxes into the physical -/// surface. -pub fn toNdcX(x: f32) f32 { - const dw: f32 = @floatFromInt(design_w); - const raw = (transformX(x) / dw) * 2.0 - 1.0; - return raw * fit_scale_x; -} - -/// Convert a design-pixel Y to NDC (flipped for the GPU), applying the -/// active camera transform, then the aspect-fit. -pub fn toNdcY(y: f32) f32 { - const dh: f32 = @floatFromInt(design_h); - const raw = 1.0 - (transformY(y) / dh) * 2.0; - return raw * fit_scale_y; -} - -pub fn fitScaleX() f32 { - return fit_scale_x; -} - -pub fn fitScaleY() f32 { - return fit_scale_y; -} - -// ── Public camera control / Backend-contract utilities ─────────────── - -pub fn beginMode2D(camera: Camera2D) void { - active_camera = camera; -} - -pub fn endMode2D() void { - active_camera = null; -} - -// Backend contract: return the DESIGN canvas so engine/camera math stays -// resolution-independent (matches the bgfx/sokol backends). Physical size -// lives in screen_w/h and is used only for the fit scale. -pub fn getScreenWidth() i32 { - return design_w; -} - -pub fn getScreenHeight() i32 { - return design_h; -} - -/// Set the design (logical) canvas size — the resolution game code operates -/// in (project width/height). Recomputes the design→physical fit. Real -/// implementation (replaces wgpu's former no-op); window.zig calls this at -/// init with the logical window size. -pub fn setDesignSize(w: i32, h: i32) void { - design_w = @max(1, w); - design_h = @max(1, h); - recomputeFitScale(); -} - -/// Design (logical) canvas dimensions — parity with the bgfx/sokol -/// backends' public surface. -pub fn getDesignWidth() i32 { - return design_w; -} - -pub fn getDesignHeight() i32 { - return design_h; -} - -pub fn screenToWorld(pos: Vector2, camera: Camera2D) Vector2 { - return .{ - .x = (pos.x - camera.offset.x) / camera.zoom + camera.target.x, - .y = (pos.y - camera.offset.y) / camera.zoom + camera.target.y, - }; -} - -pub fn worldToScreen(pos: Vector2, camera: Camera2D) Vector2 { - return .{ - .x = (pos.x - camera.target.x) * camera.zoom + camera.offset.x, - .y = (pos.y - camera.target.y) * camera.zoom + camera.offset.y, - }; -} diff --git a/backends/wgpu/src/gfx/texture.zig b/backends/wgpu/src/gfx/texture.zig deleted file mode 100644 index 78980bbc..00000000 --- a/backends/wgpu/src/gfx/texture.zig +++ /dev/null @@ -1,684 +0,0 @@ -/// Texture storage + CPU image decoders (BMP / TGA / PNG) for the WebGPU -/// backend. Owns the texture-slot pool: decoded RGBA8 pixels are retained -/// here so the window submitter can lazily create + upload a wgpu texture -/// the first time an id is drawn (gfx loads pixels on a worker thread -/// before the GPU may be ready). No native wgpu dep is referenced — this -/// module is pure CPU and host-testable. -const std = @import("std"); - -const types = @import("types.zig"); -const astc = @import("astc.zig"); -const Texture = types.Texture; - -// ── Texture storage ──────────────────────────────────────────────────── - -const MAX_TEXTURES = 256; - -const TextureSlot = struct { - /// Raw RGBA8 pixel data (owned), OR — for a GPU-compressed (ASTC) - /// slot — the raw compressed block payload (also owned, see - /// `compressed` below). null means the slot has no CPU-side bytes. - pixels: ?[]u8 = null, - width: i32 = 0, - height: i32 = 0, - active: bool = false, - /// Set when this slot holds a GPU-compressed (ASTC) blob rather than - /// decoded RGBA8. The window submitter reads it (via - /// `getCompressedTexture`) to create the matching ASTC wgpu texture and - /// `writeTexture` the blocks with the compressed data layout. null = - /// ordinary RGBA8 texture (read via `getTexturePixels`). - compressed: ?CompressedInfo = null, -}; - -/// ASTC block dimensions for a compressed slot. The submitter maps these to -/// the wgpu `TextureFormat` (4x4 / 6x6 / 8x8 / …) and derives the compressed -/// `bytes_per_row` / `rows_per_image`. The block payload itself lives in the -/// slot's `pixels` field (owned, uploaded verbatim). -const CompressedInfo = struct { - block_x: u8, - block_y: u8, -}; - -var textures: [MAX_TEXTURES]TextureSlot = [_]TextureSlot{.{}} ** MAX_TEXTURES; -var next_texture_id: u32 = 1; - -// Zig 0.16 removed `std.fs.cwd()` in favour of `std.Io.Dir.cwd()`, which -// requires an `Io` parameter threaded through the call site. This is -// the legacy path-based texture loader — production texture loading -// goes through `decodeImage` + `uploadTexture` on caller-provided -// bytes and never touches the FS directly. Rather than thread `Io` -// through the backend for a one-shot loader, we use libc `fopen` / -// `fread` / `fclose` to keep the existing `(path) !Texture` signature. -// The `link_libc = true` flag on the gfx module (see -// backends/wgpu/build.zig) pulls libc in. -const SEEK_SET: c_int = 0; -const SEEK_END: c_int = 2; -extern "c" fn fseek(stream: *std.c.FILE, offset: c_long, whence: c_int) c_int; -extern "c" fn ftell(stream: *std.c.FILE) c_long; - -pub fn loadTexture(path: [:0]const u8) !Texture { - // Read the file from disk via libc. See the rationale block above. - const file = std.c.fopen(path.ptr, "rb") orelse return error.LoadFailed; - defer _ = std.c.fclose(file); - - if (fseek(file, 0, SEEK_END) != 0) return error.LoadFailed; - const file_size_signed = ftell(file); - if (file_size_signed < 18) return error.LoadFailed; // Too small for any image header - if (fseek(file, 0, SEEK_SET) != 0) return error.LoadFailed; - const file_size: usize = @intCast(file_size_signed); - - const allocator = std.heap.page_allocator; - const file_buf = allocator.alloc(u8, file_size) catch return error.LoadFailed; - defer allocator.free(file_buf); - - const bytes_read = std.c.fread(file_buf.ptr, 1, file_size, file); - if (bytes_read != file_size) return error.LoadFailed; - - const decoded = try decodeImage("", file_buf[0..bytes_read], allocator); - defer allocator.free(decoded.pixels); - return uploadTexture(decoded); -} - -/// Pure CPU decode, safe from a worker thread. wgpu's backend ships -/// hand-rolled BMP, TGA and PNG decoders (no stb_image link). We sniff -/// the signature and dispatch: PNG first (it has an unambiguous 8-byte -/// magic), then BMP, then TGA (which has no magic, so it's the -/// last-resort fallback). The caller's allocator owns the returned -/// `pixels` buffer and frees it on both the success and the discard -/// paths. -pub fn decodeImage( - _: [:0]const u8, - data: []const u8, - allocator: std.mem.Allocator, -) !DecodedImage { - if (decodePng(data, allocator)) |img| return img; - if (decodeBmp(data, allocator)) |img| return img; - if (decodeTga(data, allocator)) |img| return img; - return error.LoadFailed; -} - -/// Main/GL-thread GPU upload. This wgpu backend currently retains its -/// decoded pixels in the texture slot (drawTexturePro uploads them -/// lazily via `wgpuQueueWriteTexture` — or a stub path, depending on -/// renderer state), so we COPY `decoded.pixels` into a fresh -/// page_allocator buffer that the slot owns. We do NOT free -/// `decoded.pixels` — the caller owns that buffer on both the success -/// and the discard paths. -pub fn uploadTexture(decoded: DecodedImage) !Texture { - const id = next_texture_id; - if (id >= MAX_TEXTURES) return error.LoadFailed; - if (decoded.width == 0 or decoded.height == 0) return error.LoadFailed; - - const owned = std.heap.page_allocator.alloc(u8, decoded.pixels.len) catch return error.LoadFailed; - @memcpy(owned, decoded.pixels); - - const w: i32 = @intCast(decoded.width); - const h: i32 = @intCast(decoded.height); - textures[id] = .{ .pixels = owned, .width = w, .height = h, .active = true }; - next_texture_id += 1; - return Texture{ .id = id, .width = w, .height = h }; -} - -pub fn unloadTexture(texture: Texture) void { - if (texture.id >= MAX_TEXTURES) return; - const slot = &textures[texture.id]; - if (slot.pixels) |px| { - std.heap.page_allocator.free(px); - } - slot.* = .{}; -} - -/// CPU-side description of a loaded texture's pixel data, used by the GPU -/// submitter (window.zig) to lazily create + upload a wgpu texture the -/// first time the texture id is drawn. The `pixels` slice is borrowed -/// (owned by the texture slot) and stays valid until `unloadTexture`. -pub const TexturePixels = struct { - pixels: []const u8, - width: u32, - height: u32, -}; - -/// Look up the RGBA8 pixel buffer for a texture id. Returns null for an -/// unknown / inactive id, OR for a GPU-compressed (ASTC) slot — those are -/// fetched via `getCompressedTexture` instead, so a compressed id never -/// reaches the RGBA8 upload path. The returned slice is borrowed (see above). -pub fn getTexturePixels(id: u32) ?TexturePixels { - if (id == 0 or id >= MAX_TEXTURES) return null; - const slot = &textures[id]; - if (!slot.active) return null; - if (slot.compressed != null) return null; // compressed slots: see getCompressedTexture - const px = slot.pixels orelse return null; - return .{ - .pixels = px, - .width = @intCast(slot.width), - .height = @intCast(slot.height), - }; -} - -// ── GPU-compressed textures (ASTC) ────────────────────────────────────────── -// The engine's `loadTextureFromMemory` seam (labelle-gfx) dispatches here when -// this backend exposes `isCompressed`/`uploadCompressed` and the blob is -// compressed, skipping the CPU PNG/BMP/TGA decode entirely (labelle-gfx#269 / -// #341). Unlike the bgfx backend — where `uploadCompressed` creates the GPU -// texture inline — this wgpu backend decouples CPU load from GPU upload (gfx -// loads bytes on a worker thread before the device may be ready). So -// `uploadCompressed` runs CPU-side: it validates + RETAINS the compressed -// block payload in a texture slot, and `window.zig`'s `getOrCreateGpuTexture` -// lazily creates the ASTC wgpu texture from it on the main thread (via -// `getCompressedTexture`), mirroring the RGBA8 lazy-upload path. - -/// Block dimensions of a validated 2D ASTC blob, plus its (owned-by-caller) -/// compressed block payload. `block_x`/`block_y` map to the wgpu ASTC -/// `TextureFormat` in the submitter; `validateAstc` rejects anything we can't -/// hand straight to the GPU so the `isCompressed` probe and the actual upload -/// never disagree. -const AstcUpload = struct { - block_x: u8, - block_y: u8, - width: u32, - height: u32, - blocks: []const u8, -}; - -/// True if `block_x`×`block_y` is one of the ASTC LDR block sizes the wgpu -/// `TextureFormat` enum exposes (the full 4x4…12x12 set). The submitter does -/// the actual enum mapping; we only need a yes/no here so the upload probe and -/// the upload agree on which blobs are acceptable. -fn astcBlockSupported(block_x: u8, block_y: u8) bool { - return switch ((@as(u16, block_x) << 8) | block_y) { - 0x0404, 0x0504, 0x0505, 0x0605, 0x0606, 0x0805, 0x0806, 0x0808, - 0x0a05, 0x0a06, 0x0a08, 0x0a0a, 0x0c0a, 0x0c0c => true, - else => false, - }; -} - -/// Validate an ASTC blob for a 2D wgpu upload, or null if we can't take it -/// as-is: not ASTC, malformed/truncated, 3D (`depth`/`block_z != 1`), or an -/// unsupported block size. `isCompressed`/`uploadCompressed` share this so the -/// "can upload as-is" probe and the actual upload never disagree. -fn validateAstc(data: []const u8) ?AstcUpload { - const hdr = astc.parse(data) orelse return null; - if (hdr.depth != 1 or hdr.block_z != 1) return null; // 2D textures only - if (!astcBlockSupported(hdr.block_x, hdr.block_y)) return null; - return .{ - .block_x = hdr.block_x, - .block_y = hdr.block_y, - .width = hdr.width, - .height = hdr.height, - .blocks = hdr.blocks, - }; -} - -/// True if `data` is a GPU-compressed blob this backend can upload as-is. -/// Consumed by labelle-gfx's `loadTextureFromMemory` seam via `@hasDecl`. -pub fn isCompressed(data: []const u8) bool { - return validateAstc(data) != null; -} - -/// Image dimensions of a compressed blob, read from the ASTC header without -/// decoding — lets the async asset-catalog adapter set a correct DecodedImage -/// width/height before upload. Null if not an ASTC blob we accept. -pub fn compressedDims(data: []const u8) ?struct { width: u32, height: u32 } { - const info = validateAstc(data) orelse return null; - return .{ .width = @intCast(info.width), .height = @intCast(info.height) }; -} - -/// Retain a validated ASTC blob for a (later, main-thread) GPU upload — no CPU -/// decode. Runs on the gfx worker thread, so it does NOT touch the GPU: it -/// copies the compressed block payload into a slot-owned buffer and records the -/// block size; `window.zig` creates the ASTC wgpu texture lazily on first draw -/// (see `getCompressedTexture`). Mirrors `uploadTexture`'s slot ownership. -pub fn uploadCompressed(data: []const u8) !Texture { - const info = validateAstc(data) orelse return error.LoadFailed; - const id = next_texture_id; - if (id >= MAX_TEXTURES) return error.LoadFailed; - if (info.width == 0 or info.height == 0) return error.LoadFailed; - - const owned = std.heap.page_allocator.alloc(u8, info.blocks.len) catch return error.LoadFailed; - @memcpy(owned, info.blocks); - - const w: i32 = @intCast(info.width); - const h: i32 = @intCast(info.height); - textures[id] = .{ - .pixels = owned, - .width = w, - .height = h, - .active = true, - .compressed = .{ .block_x = info.block_x, .block_y = info.block_y }, - }; - next_texture_id += 1; - return Texture{ .id = id, .width = w, .height = h }; -} - -/// CPU-side description of a loaded GPU-compressed (ASTC) texture, used by the -/// window submitter to lazily create + upload the ASTC wgpu texture the first -/// time the id is drawn. `blocks` is borrowed (owned by the slot, valid until -/// `unloadTexture`); `block_x`/`block_y` select the wgpu ASTC `TextureFormat` -/// and the compressed `bytes_per_row` / `rows_per_image`. -pub const CompressedTexture = struct { - blocks: []const u8, - width: u32, - height: u32, - block_x: u8, - block_y: u8, -}; - -/// Look up the compressed (ASTC) blob for a texture id, or null for an -/// unknown / inactive / non-compressed id. The returned slice is borrowed. -pub fn getCompressedTexture(id: u32) ?CompressedTexture { - if (id == 0 or id >= MAX_TEXTURES) return null; - const slot = &textures[id]; - if (!slot.active) return null; - const c = slot.compressed orelse return null; - const blocks = slot.pixels orelse return null; - return .{ - .blocks = blocks, - .width = @intCast(slot.width), - .height = @intCast(slot.height), - .block_x = c.block_x, - .block_y = c.block_y, - }; -} - -// ── Image decoding helpers ───────────────────────────────────────────── - -/// CPU-decoded image owned by the caller's allocator. See sokol's -/// `DecodedImage` doc-comment for why this is defined per-backend -/// instead of imported from labelle-gfx — same reasoning applies. -pub const DecodedImage = struct { - pixels: []u8, - width: u32, - height: u32, -}; - -/// Decode an uncompressed 24-bit or 32-bit BMP to RGBA8. -pub fn decodeBmp(data: []const u8, allocator: std.mem.Allocator) ?DecodedImage { - if (data.len < 54) return null; - if (data[0] != 'B' or data[1] != 'M') return null; - - const pixel_offset = std.mem.readInt(u32, data[10..14], .little); - const w_signed = std.mem.readInt(i32, data[18..22], .little); - const h_signed = std.mem.readInt(i32, data[22..26], .little); - const bpp = std.mem.readInt(u16, data[28..30], .little); - - if (w_signed <= 0) return null; - const width: u32 = @intCast(w_signed); - // BMP height can be negative (top-down); handle both. - const flip = h_signed > 0; - const height: u32 = if (h_signed < 0) @intCast(-h_signed) else @intCast(h_signed); - - if (bpp != 24 and bpp != 32) return null; // Only uncompressed RGB/RGBA - - const bytes_per_pixel: u32 = @as(u32, bpp) / 8; - // Widen to usize before multiplying: a large `width` from an untrusted - // header would overflow the u32 product to 0, yielding row_size 0 and a - // corrupted decode (every row re-reads the same offset). - const row_size = ((@as(usize, width) * @as(usize, bytes_per_pixel) + 3) / 4) * 4; // BMP rows are 4-byte aligned - - // `width`/`height` come straight from untrusted BMP headers, so the - // size arithmetic uses checked ops — an overflowed product would - // otherwise under-allocate `pixels` and let the copy loop write OOB. - const out_size = std.math.mul(usize, std.math.mul(usize, width, height) catch return null, 4) catch return null; - const pixels = allocator.alloc(u8, out_size) catch return null; - - var y: u32 = 0; - while (y < height) : (y += 1) { - const src_y = if (flip) height - 1 - y else y; - const row_off = @as(usize, pixel_offset) + @as(usize, src_y) * @as(usize, row_size); - var x: u32 = 0; - while (x < width) : (x += 1) { - const src = row_off + @as(usize, x) * @as(usize, bytes_per_pixel); - const dst = (@as(usize, y) * @as(usize, width) + @as(usize, x)) * 4; - if (src + bytes_per_pixel > data.len or dst + 4 > pixels.len) { - allocator.free(pixels); - return null; - } - // BMP stores BGR(A) - pixels[dst + 0] = data[src + 2]; // R - pixels[dst + 1] = data[src + 1]; // G - pixels[dst + 2] = data[src + 0]; // B - pixels[dst + 3] = if (bytes_per_pixel == 4) data[src + 3] else 255; - } - } - - return DecodedImage{ .pixels = pixels, .width = width, .height = height }; -} - -/// Decode an uncompressed TGA (type 2) to RGBA8. -pub fn decodeTga(data: []const u8, allocator: std.mem.Allocator) ?DecodedImage { - if (data.len < 18) return null; - - const image_type = data[2]; - if (image_type != 2) return null; // Only uncompressed true-color - - const width: u32 = std.mem.readInt(u16, data[12..14], .little); - const height: u32 = std.mem.readInt(u16, data[14..16], .little); - const bpp = data[16]; - const descriptor = data[17]; - - if (width == 0 or height == 0) return null; - if (bpp != 24 and bpp != 32) return null; - - const id_len: usize = data[0]; - const pixel_offset: usize = 18 + id_len; - const bytes_per_pixel: usize = @as(usize, bpp) / 8; - // Bit 5 of descriptor: 0 = bottom-up (default TGA), 1 = top-down - const top_down = (descriptor & 0x20) != 0; - - // `width`/`height` come straight from untrusted TGA headers, so the - // size arithmetic uses checked ops — an overflowed product would - // otherwise under-allocate `pixels` and let the copy loop write OOB. - const out_size = std.math.mul(usize, std.math.mul(usize, width, height) catch return null, 4) catch return null; - const pixels = allocator.alloc(u8, out_size) catch return null; - - var y: u32 = 0; - while (y < height) : (y += 1) { - const src_y = if (!top_down) height - 1 - y else y; - var x: u32 = 0; - while (x < width) : (x += 1) { - const src = pixel_offset + (@as(usize, src_y) * @as(usize, width) + @as(usize, x)) * bytes_per_pixel; - const dst = (@as(usize, y) * @as(usize, width) + @as(usize, x)) * 4; - if (src + bytes_per_pixel > data.len or dst + 4 > pixels.len) { - allocator.free(pixels); - return null; - } - // TGA stores BGR(A) - pixels[dst + 0] = data[src + 2]; // R - pixels[dst + 1] = data[src + 1]; // G - pixels[dst + 2] = data[src + 0]; // B - pixels[dst + 3] = if (bytes_per_pixel == 4) data[src + 3] else 255; - } - } - - return DecodedImage{ .pixels = pixels, .width = width, .height = height }; -} - -/// Decode a non-interlaced, 8-bit PNG to RGBA8. -/// -/// Supported subset (returns `null` for anything outside it): -/// • Bit depth: 8 only (1/2/4/16 rejected). -/// • Interlace: 0 (none) only — Adam7 interlacing is rejected. -/// • Color types: -/// 0 grayscale → gray replicated to RGB, A = 255 -/// 2 truecolor (RGB) → RGB, A = 255 -/// 3 indexed (palette) → PLTE lookup, optional tRNS for alpha -/// 4 grayscale+alpha → gray replicated to RGB, A from sample -/// 6 truecolor+alpha → RGBA passthrough -/// -/// PNG pipeline: validate the 8-byte signature, walk IHDR/PLTE/tRNS/IDAT/ -/// IEND chunks, concatenate all IDAT data, zlib-inflate it (std -/// `compress.flate` — no DEFLATE is hand-rolled), then unfilter the -/// scanlines (filter types 0–4: None/Sub/Up/Average/Paeth) and expand -/// each pixel to RGBA8. Chunk CRCs are not verified (we trust the -/// inflate + structural checks). The caller's allocator owns the -/// returned `pixels`. -pub fn decodePng(data: []const u8, allocator: std.mem.Allocator) ?DecodedImage { - const sig = [_]u8{ 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }; - if (data.len < sig.len or !std.mem.eql(u8, data[0..sig.len], &sig)) return null; - - var width: u32 = 0; - var height: u32 = 0; - var bit_depth: u8 = 0; - var color_type: u8 = 0; - var interlace: u8 = 0; - var seen_ihdr = false; - - // Palette (color type 3): up to 256 RGB entries + optional per-index alpha. - var palette: [256][3]u8 = undefined; - var palette_alpha: [256]u8 = [_]u8{255} ** 256; - var palette_len: usize = 0; - - // Concatenated IDAT payload (the zlib stream). Owned here, freed below. - var idat: std.ArrayListUnmanaged(u8) = .empty; - defer idat.deinit(allocator); - - // Walk chunks: 4-byte length, 4-byte type, length bytes data, 4-byte CRC. - var pos: usize = sig.len; - var saw_iend = false; - while (data.len - pos >= 8) { - const chunk_len = std.mem.readInt(u32, data[pos..][0..4], .big); - const ctype = data[pos + 4 ..][0..4]; - const body_start = pos + 8; - // Bounds via subtraction so a malformed `chunk_len` (e.g. - // 0xFFFFFFFF) can't overflow `usize` and bypass the check. We - // need `chunk_len` body bytes plus a 4-byte trailing CRC. - if (data.len - body_start < chunk_len) return null; // truncated body - if (data.len - body_start - chunk_len < 4) return null; // missing CRC - const body_end = body_start + chunk_len; - const body = data[body_start..body_end]; - - if (std.mem.eql(u8, ctype, "IHDR")) { - if (chunk_len != 13) return null; - width = std.mem.readInt(u32, body[0..4], .big); - height = std.mem.readInt(u32, body[4..8], .big); - bit_depth = body[8]; - color_type = body[9]; - // body[10] = compression (only 0 defined), body[11] = filter - // method (only 0 defined), body[12] = interlace. - interlace = body[12]; - seen_ihdr = true; - } else if (std.mem.eql(u8, ctype, "PLTE")) { - if (chunk_len % 3 != 0) return null; - palette_len = chunk_len / 3; - if (palette_len > 256) return null; - var i: usize = 0; - while (i < palette_len) : (i += 1) { - palette[i] = .{ body[i * 3 + 0], body[i * 3 + 1], body[i * 3 + 2] }; - } - } else if (std.mem.eql(u8, ctype, "tRNS")) { - // For indexed images, tRNS is a list of per-index alpha values. - // (We only support tRNS for color type 3; other types fall back - // to opaque alpha, which is a documented limitation.) - if (color_type == 3) { - const n = @min(chunk_len, palette_alpha.len); - var i: usize = 0; - while (i < n) : (i += 1) palette_alpha[i] = body[i]; - } - } else if (std.mem.eql(u8, ctype, "IDAT")) { - idat.appendSlice(allocator, body) catch return null; - } else if (std.mem.eql(u8, ctype, "IEND")) { - saw_iend = true; - break; - } - - pos = body_end + 4; // skip CRC - } - - if (!seen_ihdr or !saw_iend) return null; - if (width == 0 or height == 0) return null; - if (interlace != 0) return null; // Adam7 not supported - if (bit_depth != 8) return null; // only 8-bit samples supported - if (color_type == 3 and palette_len == 0) return null; - - // Samples (bytes) per pixel in the raw (filtered) scanline. - const channels: usize = switch (color_type) { - 0 => 1, // grayscale - 2 => 3, // truecolor - 3 => 1, // indexed (1 byte = palette index) - 4 => 2, // grayscale + alpha - 6 => 4, // truecolor + alpha - else => return null, - }; - - // Inflate the concatenated IDAT zlib stream. Each scanline is - // prefixed by a 1-byte filter type, so raw size = h * (1 + w*channels). - // `width`/`height` come straight from untrusted IHDR, so the size - // arithmetic uses checked ops — an overflowed product would otherwise - // under-allocate `raw` and let the unfilter loop write out of bounds. - const stride = std.math.mul(usize, width, channels) catch return null; // bytes per row, no filter byte - const row_len = std.math.add(usize, stride, 1) catch return null; // + filter byte - const raw_size = std.math.mul(usize, height, row_len) catch return null; - - const raw = allocator.alloc(u8, raw_size) catch return null; - defer allocator.free(raw); - - { - var in_reader = std.Io.Reader.fixed(idat.items); - var out_writer = std.Io.Writer.fixed(raw); - // Empty window buffer = "direct" mode; flate reads straight from the - // fixed input. `.zlib` container handles the 2-byte zlib header + - // Adler-32 footer that wraps PNG's DEFLATE stream. - var decompress = std.compress.flate.Decompress.init(&in_reader, .zlib, &.{}); - const n = decompress.reader.streamRemaining(&out_writer) catch return null; - if (n != raw_size) return null; // wrong amount of data - } - - // Output RGBA8 buffer. Checked arithmetic for the same untrusted-dims - // overflow reason as `raw_size` above. (No `errdefer` here: this - // function returns `?DecodedImage`, not an error union, so an errdefer - // would never fire — the failure paths below free `pixels` manually.) - const out_size = std.math.mul(usize, std.math.mul(usize, width, height) catch return null, 4) catch return null; - const pixels = allocator.alloc(u8, out_size) catch return null; - - // Unfilter scanlines in place within `raw` (we overwrite the filtered - // bytes with reconstructed ones, row by row, top to bottom). - var y: usize = 0; - while (y < height) : (y += 1) { - const row_off = y * (1 + stride); - const filter = raw[row_off]; - const cur = raw[row_off + 1 ..][0..stride]; - const prev: ?[]const u8 = if (y == 0) - null - else - raw[(y - 1) * (1 + stride) + 1 ..][0..stride]; - - var i: usize = 0; - while (i < stride) : (i += 1) { - const a: i32 = if (i >= channels) cur[i - channels] else 0; // left - const b: i32 = if (prev) |p| p[i] else 0; // up - const c: i32 = if (prev != null and i >= channels) prev.?[i - channels] else 0; // up-left - const x: i32 = cur[i]; - const recon: i32 = switch (filter) { - 0 => x, // None - 1 => x + a, // Sub - 2 => x + b, // Up - 3 => x + @divFloor(a + b, 2), // Average - 4 => x + paeth(a, b, c), // Paeth - else => { - allocator.free(pixels); - return null; - }, - }; - cur[i] = @truncate(@as(u32, @bitCast(recon))); - } - - // Expand this reconstructed scanline to RGBA8. - var px: usize = 0; - while (px < width) : (px += 1) { - const dst = (y * @as(usize, width) + px) * 4; - switch (color_type) { - 0 => { // grayscale - const g = cur[px]; - pixels[dst + 0] = g; - pixels[dst + 1] = g; - pixels[dst + 2] = g; - pixels[dst + 3] = 255; - }, - 2 => { // truecolor RGB - const s = px * 3; - pixels[dst + 0] = cur[s + 0]; - pixels[dst + 1] = cur[s + 1]; - pixels[dst + 2] = cur[s + 2]; - pixels[dst + 3] = 255; - }, - 3 => { // indexed - const idx = cur[px]; - if (idx >= palette_len) { - allocator.free(pixels); - return null; - } - pixels[dst + 0] = palette[idx][0]; - pixels[dst + 1] = palette[idx][1]; - pixels[dst + 2] = palette[idx][2]; - pixels[dst + 3] = palette_alpha[idx]; - }, - 4 => { // grayscale + alpha - const s = px * 2; - const g = cur[s + 0]; - pixels[dst + 0] = g; - pixels[dst + 1] = g; - pixels[dst + 2] = g; - pixels[dst + 3] = cur[s + 1]; - }, - 6 => { // truecolor + alpha - const s = px * 4; - pixels[dst + 0] = cur[s + 0]; - pixels[dst + 1] = cur[s + 1]; - pixels[dst + 2] = cur[s + 2]; - pixels[dst + 3] = cur[s + 3]; - }, - else => unreachable, - } - } - } - - return DecodedImage{ .pixels = pixels, .width = width, .height = height }; -} - -/// PNG Paeth predictor (filter type 4). Operates on i32 to avoid the -/// wraparound that the spec's byte arithmetic would otherwise mask. -fn paeth(a: i32, b: i32, c: i32) i32 { - const p = a + b - c; - const pa = @abs(p - a); - const pb = @abs(p - b); - const pc = @abs(p - c); - if (pa <= pb and pa <= pc) return a; - if (pb <= pc) return b; - return c; -} - -// ── ASTC seam tests (pure CPU; no wgpu) ───────────────────────────────────── -// Exercise the `isCompressed` / `uploadCompressed` / `getCompressedTexture` -// contract the labelle-gfx seam + window submitter rely on. The astc.zig -// parser has its own tests; these cover the wgpu-side validation + slot -// retention. They share the global slot table, so each grabs the id it just -// minted and unloads it to keep the pool clean for sibling tests. - -fn makeAstc(buf: *[16 + 1024]u8, bx: u8, by: u8) void { - // 64x64 @ 8x8 = 8*8 blocks * 16 bytes = 1024 block bytes (fits the buf). - @memcpy(buf[0..4], &astc.MAGIC); - buf[4] = bx; - buf[5] = by; - buf[6] = 1; - std.mem.writeInt(u24, buf[7..10], 64, .little); - std.mem.writeInt(u24, buf[10..13], 64, .little); - std.mem.writeInt(u24, buf[13..16], 1, .little); - @memset(buf[16..], 0xAB); -} - -test "isCompressed: true for a supported ASTC blob, false otherwise" { - var buf: [16 + 1024]u8 = undefined; - makeAstc(&buf, 8, 8); - try std.testing.expect(isCompressed(&buf)); - // A real PNG is NOT compressed-as-is (it routes through the CPU decoder). - const not_astc = [_]u8{ 0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0 }; - try std.testing.expect(!isCompressed(¬_astc)); -} - -test "uploadCompressed: retains blocks + block size, readable via getCompressedTexture" { - var buf: [16 + 1024]u8 = undefined; - makeAstc(&buf, 8, 8); - const tex = try uploadCompressed(&buf); - defer unloadTexture(tex); - - try std.testing.expectEqual(@as(i32, 64), tex.width); - try std.testing.expectEqual(@as(i32, 64), tex.height); - - const c = getCompressedTexture(tex.id) orelse return error.TestUnexpected; - try std.testing.expectEqual(@as(u8, 8), c.block_x); - try std.testing.expectEqual(@as(u8, 8), c.block_y); - try std.testing.expectEqual(@as(u32, 64), c.width); - try std.testing.expectEqual(@as(usize, 1024), c.blocks.len); - try std.testing.expectEqual(@as(u8, 0xAB), c.blocks[0]); - - // A compressed slot must NOT surface through the RGBA8 path (or the - // submitter would try to upload ASTC blocks as rgba8_unorm). - try std.testing.expect(getTexturePixels(tex.id) == null); -} - -test "uploadCompressed: rejects an unsupported block size" { - var buf: [16 + 1024]u8 = undefined; - makeAstc(&buf, 7, 7); // 7x7 is not an ASTC LDR block size - try std.testing.expect(!isCompressed(&buf)); - try std.testing.expectError(error.LoadFailed, uploadCompressed(&buf)); -} diff --git a/backends/wgpu/src/gfx/types.zig b/backends/wgpu/src/gfx/types.zig deleted file mode 100644 index 86007b43..00000000 --- a/backends/wgpu/src/gfx/types.zig +++ /dev/null @@ -1,83 +0,0 @@ -/// Pure-data value types, color constants, and vertex formats for the -/// WebGPU gfx backend. State-free and side-effect-free, so every other -/// gfx submodule can import it without creating cycles. - -pub const Texture = struct { - id: u32, - width: i32, - height: i32, -}; - -pub const Color = struct { - r: u8, - g: u8, - b: u8, - a: u8, - - /// Convert to packed ABGR u32 for vertex data. - pub fn toAbgr(self: Color) u32 { - return (@as(u32, self.a) << 24) | - (@as(u32, self.b) << 16) | - (@as(u32, self.g) << 8) | - @as(u32, self.r); - } -}; - -pub const Rectangle = struct { - x: f32, - y: f32, - width: f32, - height: f32, -}; - -pub const Vector2 = struct { - x: f32, - y: f32, -}; - -pub const Camera2D = struct { - offset: Vector2 = .{ .x = 0, .y = 0 }, - target: Vector2 = .{ .x = 0, .y = 0 }, - rotation: f32 = 0, - zoom: f32 = 1, -}; - -// ── Color constants ──────────────────────────────────────────────────── - -pub const white = Color{ .r = 255, .g = 255, .b = 255, .a = 255 }; -pub const black = Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; -pub const red = Color{ .r = 255, .g = 0, .b = 0, .a = 255 }; -pub const green = Color{ .r = 0, .g = 255, .b = 0, .a = 255 }; -pub const blue = Color{ .r = 0, .g = 0, .b = 255, .a = 255 }; -pub const transparent = Color{ .r = 0, .g = 0, .b = 0, .a = 0 }; - -pub fn color(r: u8, g: u8, b: u8, a: u8) Color { - return .{ .r = r, .g = g, .b = b, .a = a }; -} - -// ── Vertex types ────────────────────────────────────────────────────── - -/// Color vertex for shape rendering (position + packed ABGR color). -/// Pub: it is the element type of `consumeShapeBatch`'s return slices, -/// which the window module's render submitter consumes. -pub const ColorVertex = extern struct { - position: [2]f32, - color_packed: u32, // ABGR packed - - pub fn init(x: f32, y: f32, col: u32) ColorVertex { - return .{ .position = .{ x, y }, .color_packed = col }; - } -}; - -/// Sprite vertex with position, UV, and packed ABGR color. -/// Pub: it is the element type of `consumeSpriteBatch`'s returned vertex -/// slice, which the window module's render submitter consumes. -pub const SpriteVertex = extern struct { - position: [2]f32, - uv: [2]f32, - color_packed: u32, // ABGR packed - - pub fn init(x: f32, y: f32, u: f32, v: f32, col: u32) SpriteVertex { - return .{ .position = .{ x, y }, .uv = .{ u, v }, .color_packed = col }; - } -}; diff --git a/backends/wgpu/src/input.zig b/backends/wgpu/src/input.zig deleted file mode 100644 index 0a4a85e1..00000000 --- a/backends/wgpu/src/input.zig +++ /dev/null @@ -1,163 +0,0 @@ -/// WebGPU input backend — satisfies the engine InputInterface(Impl) contract. -/// Uses GLFW for input (wgpu doesn't provide input). -const glfw = @import("zglfw"); - -const MAX_KEYS = 512; -const MAX_MOUSE_BUTTONS = 8; - -var keys_down: [MAX_KEYS]bool = [_]bool{false} ** MAX_KEYS; -var keys_pressed: [MAX_KEYS]bool = [_]bool{false} ** MAX_KEYS; -var keys_released: [MAX_KEYS]bool = [_]bool{false} ** MAX_KEYS; - -var mouse_down: [MAX_MOUSE_BUTTONS]bool = [_]bool{false} ** MAX_MOUSE_BUTTONS; -var mouse_pressed: [MAX_MOUSE_BUTTONS]bool = [_]bool{false} ** MAX_MOUSE_BUTTONS; -var mouse_released: [MAX_MOUSE_BUTTONS]bool = [_]bool{false} ** MAX_MOUSE_BUTTONS; - -var mouse_x: f32 = 0; -var mouse_y: f32 = 0; -var mouse_wheel: f32 = 0; - -var glfw_window: ?*glfw.Window = null; - -/// Bind to a GLFW window for input polling. -pub fn setWindow(win: *glfw.Window) void { - glfw_window = win; - _ = win.setScrollCallback(scrollCallback); -} - -fn scrollCallback(_: *glfw.Window, _: f64, yoffset: f64) callconv(.c) void { - mouse_wheel = @floatCast(yoffset); -} - -/// Call at the start of each frame to reset per-frame state and poll GLFW. -pub fn newFrame() void { - keys_pressed = [_]bool{false} ** MAX_KEYS; - keys_released = [_]bool{false} ** MAX_KEYS; - mouse_pressed = [_]bool{false} ** MAX_MOUSE_BUTTONS; - mouse_released = [_]bool{false} ** MAX_MOUSE_BUTTONS; - mouse_wheel = 0; - - glfw.pollEvents(); - - if (glfw_window) |win| { - // GLFW's cursor position is in LOGICAL window coordinates, but the - // engine maps input against the PHYSICAL framebuffer (gfx now renders - // at framebuffer pixels and `gfx.setScreenSize` receives them). Scale - // the cursor to framebuffer pixels using the window's own - // framebuffer/logical ratio so HiDPI/Retina hit-testing lands - // correctly: the camera's `framebufferToWorld` → `gfx.screenToDesign` - // then maps these physical pixels back to design space. Mirrors the - // bgfx backend's input scaling. - const pos = win.getCursorPos(); - const fb = win.getFramebufferSize(); - const ws = win.getSize(); - const sx: f64 = if (ws[0] > 0) @as(f64, @floatFromInt(fb[0])) / @as(f64, @floatFromInt(ws[0])) else 1.0; - const sy: f64 = if (ws[1] > 0) @as(f64, @floatFromInt(fb[1])) / @as(f64, @floatFromInt(ws[1])) else 1.0; - mouse_x = @floatCast(pos[0] * sx); - mouse_y = @floatCast(pos[1] * sy); - - // Derive this-frame mouse button press/release EDGES. GLFW exposes only - // live down-state, so compare each button against `mouse_down[]` (the - // persisted previous-frame state) to set the one-frame - // `mouse_pressed`/`mouse_released` arrays the engine's - // `isMouseButtonPressed`/`Released` read. Without this they were always - // false; only live-poll `isMouseButtonDown` worked. Mirrors bgfx. - for (0..MAX_MOUSE_BUTTONS) |b| { - const cur = win.getMouseButton(@enumFromInt(b)) == .press; - if (cur and !mouse_down[b]) mouse_pressed[b] = true; - if (!cur and mouse_down[b]) mouse_released[b] = true; - mouse_down[b] = cur; - } - } -} - -// ── Keyboard ────────────────────────────────────────────── - -pub fn isKeyDown(key: u32) bool { - if (glfw_window) |win| { - return win.getKey(@enumFromInt(key)) == .press; - } - return false; -} - -pub fn isKeyPressed(key: u32) bool { - return if (key < MAX_KEYS) keys_pressed[key] else false; -} - -pub fn isKeyReleased(key: u32) bool { - return if (key < MAX_KEYS) keys_released[key] else false; -} - -// ── Mouse ───────────────────────────────────────────────── - -pub fn getMouseX() f32 { - return mouse_x; -} - -pub fn getMouseY() f32 { - return mouse_y; -} - -pub fn isMouseButtonDown(button: u32) bool { - if (glfw_window) |win| { - return win.getMouseButton(@enumFromInt(button)) == .press; - } - return false; -} - -pub fn isMouseButtonPressed(button: u32) bool { - return if (button < MAX_MOUSE_BUTTONS) mouse_pressed[button] else false; -} - -pub fn isMouseButtonReleased(button: u32) bool { - return if (button < MAX_MOUSE_BUTTONS) mouse_released[button] else false; -} - -pub fn getMouseWheelMove() f32 { - return mouse_wheel; -} - -// ── Touch ───────────────────────────────────────────────── - -pub fn getTouchCount() u32 { - return 0; // GLFW desktop: no touch support -} - -pub fn getTouchX(index: u32) f32 { - _ = index; - return 0; -} - -pub fn getTouchY(index: u32) f32 { - _ = index; - return 0; -} - -pub fn getTouchId(index: u32) u64 { - _ = index; - return 0; -} - -// ── Gamepad ─────────────────────────────────────────────── - -pub fn isGamepadAvailable(gamepad: u32) bool { - return glfw.joystickPresent(@enumFromInt(gamepad)); -} - -pub fn isGamepadButtonDown(gamepad: u32, button: u32) bool { - _ = gamepad; - _ = button; - return false; -} - -pub fn isGamepadButtonPressed(gamepad: u32, button: u32) bool { - _ = gamepad; - _ = button; - return false; -} - -pub fn getGamepadAxisValue(gamepad: u32, axis: u32) f32 { - _ = gamepad; - _ = axis; - return 0; -} diff --git a/backends/wgpu/src/window.zig b/backends/wgpu/src/window.zig deleted file mode 100644 index 9e41454a..00000000 --- a/backends/wgpu/src/window.zig +++ /dev/null @@ -1,990 +0,0 @@ -/// WebGPU window backend — GLFW windowing + the wgpu render submitter. -/// -/// The CPU side of rendering lives in gfx.zig (NDC vertex batching); this -/// file owns the GPU spine: instance → surface (Win32 HWND) → adapter → -/// device/queue → surface configure, and the per-frame acquire → upload → -/// render-pass → submit → present that drains gfx's shape + sprite batches. -/// The batch vertices are already NDC, so both pipelines are passthrough -/// WGSL modules with no projection uniform. Shapes draw first, then sprites -/// on top (matching draw-call submission order); the sprite path samples a -/// bound texture_2d and multiplies by the per-vertex color. Text atlases -/// stay TODO — HUD text routes through gfx's bitmap-font glyph rects in the -/// shape batch. -const std = @import("std"); -const builtin = @import("builtin"); -const glfw = @import("zglfw"); -const wgpu = @import("wgpu"); -const gfx = @import("gfx"); - -pub const ConfigFlags = struct { - window_hidden: bool = false, -}; - -/// The current render-surface dimensions, in PHYSICAL framebuffer pixels. -/// -/// On a HiDPI/Retina display the GLFW framebuffer is larger than the logical -/// window size (e.g. 1600x1200 for a logical 800x600 window at 2x). We render -/// the wgpu swapchain at this physical size for crisp output, and feed it to -/// `gfx.setScreenSize` so the design canvas aspect-fits onto the real surface. -/// Seeded from `getFramebufferSize()` at window creation and reconciled every -/// frame by `ensureSurface()` (DPI move / resize / fullscreen toggle). -var screen_w: i32 = 800; -var screen_h: i32 = 600; -var glfw_window: ?*glfw.Window = null; -var target_fps_val: i32 = 60; -var window_hidden: bool = false; -/// Latched by `requestQuit()` and OR'd into `windowShouldClose`/`shouldQuit` -/// (GLFW also has its own close flag; this covers a programmatic engine quit). -var quit_requested: bool = false; -/// Previous `glfw.getTime()` reading, for `frameDuration()`. -var last_frame_time: f64 = 0; -/// Windowed-mode geometry, captured the moment we go fullscreen so -/// `setFullscreen(false)` restores the window to the same place + size -/// (GLFW's `setMonitor` needs explicit windowed coords on the way back). -var windowed_x: i32 = 0; -var windowed_y: i32 = 0; -var windowed_w: i32 = 800; -var windowed_h: i32 = 600; - -pub fn setConfigFlags(flags: ConfigFlags) void { - window_hidden = flags.window_hidden; -} - -/// The physical framebuffer size of the render surface. On a Retina/HiDPI -/// display GLFW's framebuffer is larger than the logical window size (e.g. -/// 2x), and it's the size the wgpu swapchain must match for crisp rendering. -/// Returns the cached `screen_w/h` before the window exists. -fn framebufferSize() [2]i32 { - if (glfw_window) |win| { - const fb = win.getFramebufferSize(); - return .{ @intCast(fb[0]), @intCast(fb[1]) }; - } - return .{ screen_w, screen_h }; -} - -/// Reconcile the wgpu surface with the current physical framebuffer size. -/// -/// Called once per frame from `beginDrawing`. The expensive part — the wgpu -/// surface reconfigure — only runs when the framebuffer actually changed (a -/// DPI move, resize, or fullscreen toggle), `> 0`-guarded to skip minimized -/// windows. But `gfx.setScreenSize` is re-asserted EVERY frame: it's cheap, -/// and it keeps gfx's physical dimensions authoritative even if some other -/// code (an example main, a future codegen path) sets gfx's size to -/// something else mid-frame — otherwise gfx could drift to logical size -/// while the swapchain stays physical, breaking aspect-fit + `screenToDesign` -/// input. Mirrors the bgfx backend's per-frame `ensureSurface`. -fn ensureSurface() void { - const fb = framebufferSize(); - if (fb[0] <= 0 or fb[1] <= 0) return; // minimized — nothing valid to apply - if (fb[0] != screen_w or fb[1] != screen_h) { - screen_w = fb[0]; - screen_h = fb[1]; - if (gpu_ready) { - if (surface) |s| { - if (device) |dev| { - s.configure(&wgpu.SurfaceConfiguration{ - .device = dev, - .format = .bgra8_unorm, - .width = @intCast(screen_w), - .height = @intCast(screen_h), - }); - } - } - } - } - // Re-assert gfx's physical size every frame (cheap), so it can't drift. - gfx.setScreenSize(screen_w, screen_h); -} - -// ── GPU state ─────────────────────────────────────────────────────────── - -const ShapeVertex = extern struct { position: [2]f32, color_packed: u32 }; -// Sprite vertex layout is owned by gfx.zig (the batch producer); alias it so -// the GPU-side stride / attribute offsets stay in lockstep with the CPU side. -const SpriteVertex = gfx.SpriteVertex; - -var gpu_ready = false; -/// Whether the adapter advertised — and the device was created with — the -/// `texture_compression_astc` feature (#341). Gates `getOrCreateGpuTexture`'s -/// compressed (ASTC) path: when false, ASTC slots can't be uploaded (creating -/// an ASTC texture would fail), so they're skipped with a one-time warning. -var astc_supported = false; -var astc_unsupported_warned = false; -var io_threaded: ?std.Io.Threaded = null; -var instance: ?*wgpu.Instance = null; -var surface: ?*wgpu.Surface = null; -var device: ?*wgpu.Device = null; -var queue: ?*wgpu.Queue = null; -var shape_pipeline: ?*wgpu.RenderPipeline = null; -var vertex_buffer: ?*wgpu.Buffer = null; -var index_buffer: ?*wgpu.Buffer = null; -var clear_color = wgpu.Color{ .r = 0.96, .g = 0.96, .b = 0.96, .a = 1.0 }; - -// Sprite (textured-quad) GPU state. The texture bind group layout (binding -// 1 = texture_2d, binding 2 = sampler) is shared by every per-texture bind -// group; binding 0 is unused so the sprite shader's group layout matches the -// shape shader convention (kept simple — no uniform buffer since verts are -// already NDC). -var sprite_pipeline: ?*wgpu.RenderPipeline = null; -var sprite_vertex_buffer: ?*wgpu.Buffer = null; -var sprite_index_buffer: ?*wgpu.Buffer = null; -var sprite_bind_group_layout: ?*wgpu.BindGroupLayout = null; -var sprite_sampler: ?*wgpu.Sampler = null; - -const MAX_VERTEX_BYTES: u64 = 16384 * @sizeOf(ShapeVertex); -const MAX_INDEX_BYTES: u64 = 32768 * @sizeOf(u32); -const MAX_SPRITE_VERTEX_BYTES: u64 = 8192 * @sizeOf(SpriteVertex); -const MAX_SPRITE_INDEX_BYTES: u64 = 16384 * @sizeOf(u32); - -// ── GPU texture handle table ───────────────────────────────────────────── -// Maps a gfx texture id → its uploaded wgpu texture / view / bind group. -// Textures are created lazily on first draw (gfx loads pixels on a worker -// thread before the GPU may be ready), so this table is populated from -// submitFrame on the main/GL thread. -const MAX_GPU_TEXTURES = 256; -const GpuTexture = struct { - texture: *wgpu.Texture, - view: *wgpu.TextureView, - bind_group: *wgpu.BindGroup, -}; -var gpu_textures: [MAX_GPU_TEXTURES]?GpuTexture = [_]?GpuTexture{null} ** MAX_GPU_TEXTURES; - -extern "kernel32" fn GetModuleHandleW(name: ?[*:0]const u16) callconv(.winapi) ?*anyopaque; - -/// Passthrough shaders: gfx.zig batches vertices pre-transformed to NDC, -/// so no projection uniform is needed. Color arrives packed ABGR. -const shape_wgsl = - \\struct VsOut { - \\ @builtin(position) pos: vec4, - \\ @location(0) color: vec4, - \\}; - \\ - \\@vertex - \\fn vs_main(@location(0) position: vec2, @location(1) color_packed: u32) -> VsOut { - \\ var out: VsOut; - \\ out.pos = vec4(position, 0.0, 1.0); - \\ out.color = vec4( - \\ f32(color_packed & 0xFFu) / 255.0, - \\ f32((color_packed >> 8u) & 0xFFu) / 255.0, - \\ f32((color_packed >> 16u) & 0xFFu) / 255.0, - \\ f32((color_packed >> 24u) & 0xFFu) / 255.0, - \\ ); - \\ return out; - \\} - \\ - \\@fragment - \\fn fs_main(in: VsOut) -> @location(0) vec4 { - \\ return in.color; - \\} -; - -/// Textured-quad shaders. Like the shape module, vertices arrive pre-baked to -/// NDC so there is no projection uniform. The fragment stage samples the bound -/// texture and modulates by the unpacked ABGR vertex color (tint). Binding 0 -/// is intentionally empty so the bind group layout's slot 0 stays unused, -/// keeping a single-group convention. -const sprite_wgsl = - \\struct VsOut { - \\ @builtin(position) pos: vec4, - \\ @location(0) uv: vec2, - \\ @location(1) color: vec4, - \\}; - \\ - \\@vertex - \\fn vs_main(@location(0) position: vec2, @location(1) uv: vec2, @location(2) color_packed: u32) -> VsOut { - \\ var out: VsOut; - \\ out.pos = vec4(position, 0.0, 1.0); - \\ out.uv = uv; - \\ out.color = vec4( - \\ f32(color_packed & 0xFFu) / 255.0, - \\ f32((color_packed >> 8u) & 0xFFu) / 255.0, - \\ f32((color_packed >> 16u) & 0xFFu) / 255.0, - \\ f32((color_packed >> 24u) & 0xFFu) / 255.0, - \\ ); - \\ return out; - \\} - \\ - \\@group(0) @binding(1) var t_diffuse: texture_2d; - \\@group(0) @binding(2) var s_diffuse: sampler; - \\ - \\@fragment - \\fn fs_main(in: VsOut) -> @location(0) vec4 { - \\ return textureSample(t_diffuse, s_diffuse, in.uv) * in.color; - \\} -; - -const log = std.log.scoped(.wgpu_window); - -// ── Apple platform surface (Cocoa NSWindow → CAMetalLayer) ─────────────── -// wgpu-native wants a CAMetalLayer to back the surface on macOS/iOS. GLFW -// gives us the NSWindow; we attach a fresh CAMetalLayer to its content view -// via the Objective-C runtime (no objc headers needed — three msgSends). -// Symbols resolve through the Foundation/QuartzCore frameworks the consuming -// executable links. -const ObjcId = ?*anyopaque; -extern "c" fn objc_getClass(name: [*:0]const u8) ObjcId; -extern "c" fn sel_registerName(name: [*:0]const u8) ?*anyopaque; -extern "c" fn objc_msgSend() void; - -fn attachMetalLayer(nswindow: *anyopaque) ?*anyopaque { - // objc_msgSend must be called through a prototype matching each message's - // exact ABI (arm64 has no generic variadic form), so cast per signature. - const msgId = @as(*const fn (ObjcId, ?*anyopaque) callconv(.c) ObjcId, @ptrCast(&objc_msgSend)); - const msgSetBool = @as(*const fn (ObjcId, ?*anyopaque, i8) callconv(.c) void, @ptrCast(&objc_msgSend)); - const msgSetId = @as(*const fn (ObjcId, ?*anyopaque, ObjcId) callconv(.c) void, @ptrCast(&objc_msgSend)); - - const metal_class = objc_getClass("CAMetalLayer") orelse return null; - const layer = msgId(metal_class, sel_registerName("layer")) orelse return null; // +[CAMetalLayer layer] - - const content_view = msgId(nswindow, sel_registerName("contentView")) orelse return null; - msgSetBool(content_view, sel_registerName("setWantsLayer:"), 1); // setWantsLayer:YES - msgSetId(content_view, sel_registerName("setLayer:"), layer); - return layer; -} - -fn createSurface(win: *glfw.Window) ?*wgpu.Surface { - switch (builtin.target.os.tag) { - .windows => { - const hwnd = glfw.getWin32Window(win) orelse { - log.warn("no Win32 HWND from GLFW; rendering disabled", .{}); - return null; - }; - const surface_desc = wgpu.surfaceDescriptorFromWindowsHWND(.{ - .hinstance = GetModuleHandleW(null).?, - .hwnd = hwnd, - }); - return instance.?.createSurface(&surface_desc); - }, - .macos => { - const nswindow = glfw.getCocoaWindow(win) orelse { - log.warn("no Cocoa NSWindow from GLFW; rendering disabled", .{}); - return null; - }; - const layer = attachMetalLayer(nswindow) orelse { - log.warn("failed to attach CAMetalLayer; rendering disabled", .{}); - return null; - }; - const surface_desc = wgpu.surfaceDescriptorFromMetalLayer(.{ .layer = layer }); - return instance.?.createSurface(&surface_desc); - }, - else => { - log.warn("wgpu surface creation only wired for Windows/macOS so far; rendering disabled", .{}); - return null; - }, - } -} - -fn initGpu() void { - const win = glfw_window orelse return; - - instance = wgpu.Instance.create(null) orelse { - log.warn("wgpu instance creation failed; rendering disabled", .{}); - return; - }; - - surface = createSurface(win) orelse { - log.warn("wgpu surface creation failed; rendering disabled", .{}); - // createSurface returns null on platforms without a wired surface - // (e.g. Linux) as well as on a genuine failure — release the - // instance we just created so it doesn't leak on that path. - instance.?.release(); - instance = null; - return; - }; - - io_threaded = std.Io.Threaded.init(std.heap.page_allocator, .{}); - const io = io_threaded.?.io(); - - const adapter_resp = instance.?.requestAdapterSync(&wgpu.RequestAdapterOptions{ - .compatible_surface = surface, - }, io, std.Io.Duration.fromMilliseconds(10)); - const adapter = adapter_resp.adapter orelse { - log.warn("wgpu adapter request failed: {s}; rendering disabled", .{adapter_resp.message orelse "?"}); - return; - }; - - // Request the ASTC compressed-texture feature so `uploadCompressed`'s - // ASTC textures can be created (#341 / labelle-gfx#269). It's a DEVICE - // FEATURE that must be enabled at device creation — without it, creating - // an `astc*_unorm` texture fails. Best-effort: only ask for it when the - // adapter actually advertises it (asking for an unsupported feature makes - // `requestDevice` fail outright, which would disable ALL rendering), so on - // hardware without ASTC we get a normal device and the loader simply can't - // produce ASTC textures. `astc_supported` gates the upload path below. - astc_supported = adapter.hasFeature(.texture_compression_astc); - var required_features = [_]wgpu.FeatureName{.texture_compression_astc}; - const device_desc = wgpu.DeviceDescriptor{ - .required_feature_count = if (astc_supported) required_features.len else 0, - .required_features = &required_features, - .required_limits = null, - }; - const device_resp = adapter.requestDeviceSync(instance.?, &device_desc, io, std.Io.Duration.fromMilliseconds(10)); - device = device_resp.device orelse { - log.warn("wgpu device request failed; rendering disabled", .{}); - return; - }; - if (astc_supported) { - log.info("wgpu: ASTC compressed-texture feature enabled", .{}); - } - // The adapter is only needed to create the device; drop our reference now. - defer adapter.release(); - queue = device.?.getQueue() orelse return; - - surface.?.configure(&wgpu.SurfaceConfiguration{ - .device = device.?, - .format = .bgra8_unorm, - .width = @intCast(screen_w), - .height = @intCast(screen_h), - }); - - const shader = device.?.createShaderModule(&wgpu.shaderModuleWGSLDescriptor(.{ - .code = shape_wgsl, - })) orelse { - log.warn("wgpu shader module creation failed; rendering disabled", .{}); - return; - }; - defer shader.release(); - - const attributes = [_]wgpu.VertexAttribute{ - .{ .format = .float32x2, .offset = 0, .shader_location = 0 }, - .{ .format = .uint32, .offset = 8, .shader_location = 1 }, - }; - const vertex_layout = wgpu.VertexBufferLayout{ - .array_stride = @sizeOf(ShapeVertex), - .attribute_count = attributes.len, - .attributes = &attributes, - }; - const color_target = wgpu.ColorTargetState{ - .format = .bgra8_unorm, - .blend = &wgpu.BlendState{ - .color = .{ .src_factor = .src_alpha, .dst_factor = .one_minus_src_alpha, .operation = .add }, - .alpha = .{ .src_factor = .one, .dst_factor = .one_minus_src_alpha, .operation = .add }, - }, - }; - shape_pipeline = device.?.createRenderPipeline(&wgpu.RenderPipelineDescriptor{ - .vertex = .{ - .module = shader, - .entry_point = wgpu.StringView.fromSlice("vs_main"), - .buffer_count = 1, - .buffers = &[_]wgpu.VertexBufferLayout{vertex_layout}, - }, - .fragment = &wgpu.FragmentState{ - .module = shader, - .entry_point = wgpu.StringView.fromSlice("fs_main"), - .target_count = 1, - .targets = &[_]wgpu.ColorTargetState{color_target}, - }, - .primitive = .{}, - .multisample = .{}, - }) orelse { - log.warn("wgpu pipeline creation failed; rendering disabled", .{}); - return; - }; - - vertex_buffer = device.?.createBuffer(&wgpu.BufferDescriptor{ - .size = MAX_VERTEX_BYTES, - .usage = wgpu.BufferUsages.vertex | wgpu.BufferUsages.copy_dst, - }) orelse return; - index_buffer = device.?.createBuffer(&wgpu.BufferDescriptor{ - .size = MAX_INDEX_BYTES, - .usage = wgpu.BufferUsages.index | wgpu.BufferUsages.copy_dst, - }) orelse return; - - initSpritePipeline(); - - gpu_ready = true; -} - -/// Build the textured-quad pipeline: bind group layout (texture + sampler), -/// a clamp/nearest sampler, the sprite render pipeline, and its vertex/index -/// buffers. Failures here log + leave `sprite_pipeline` null; the shape path -/// stays fully functional and sprite draws are skipped (with a warning) until -/// the pipeline exists. Must run after `device`/`queue` are live. -fn initSpritePipeline() void { - const dev = device orelse return; - - const sprite_shader = dev.createShaderModule(&wgpu.shaderModuleWGSLDescriptor(.{ - .code = sprite_wgsl, - })) orelse { - log.warn("wgpu sprite shader module creation failed; sprite rendering disabled", .{}); - return; - }; - defer sprite_shader.release(); - - // Bind group layout: binding 1 = sampled texture_2d, binding 2 = sampler. - const bgl_entries = [_]wgpu.BindGroupLayoutEntry{ - .{ - .binding = 1, - .visibility = wgpu.ShaderStages.fragment, - .texture = .{ .sample_type = .float, .view_dimension = .@"2d" }, - }, - .{ - .binding = 2, - .visibility = wgpu.ShaderStages.fragment, - .sampler = .{ .@"type" = .filtering }, - }, - }; - sprite_bind_group_layout = dev.createBindGroupLayout(&wgpu.BindGroupLayoutDescriptor{ - .entry_count = bgl_entries.len, - .entries = &bgl_entries, - }) orelse { - log.warn("wgpu sprite bind group layout creation failed; sprite rendering disabled", .{}); - return; - }; - - const pipeline_layout = dev.createPipelineLayout(&wgpu.PipelineLayoutDescriptor{ - .bind_group_layout_count = 1, - .bind_group_layouts = &[_]*wgpu.BindGroupLayout{sprite_bind_group_layout.?}, - }) orelse { - log.warn("wgpu sprite pipeline layout creation failed; sprite rendering disabled", .{}); - return; - }; - defer pipeline_layout.release(); - - sprite_sampler = dev.createSampler(&wgpu.SamplerDescriptor{ - .address_mode_u = .clamp_to_edge, - .address_mode_v = .clamp_to_edge, - .mag_filter = .nearest, - .min_filter = .nearest, - }) orelse { - log.warn("wgpu sprite sampler creation failed; sprite rendering disabled", .{}); - return; - }; - - const attributes = [_]wgpu.VertexAttribute{ - .{ .format = .float32x2, .offset = 0, .shader_location = 0 }, // position - .{ .format = .float32x2, .offset = 8, .shader_location = 1 }, // uv - .{ .format = .uint32, .offset = 16, .shader_location = 2 }, // color_packed - }; - const vertex_layout = wgpu.VertexBufferLayout{ - .array_stride = @sizeOf(SpriteVertex), - .attribute_count = attributes.len, - .attributes = &attributes, - }; - // Same straight-alpha blend as the shape pipeline. - const color_target = wgpu.ColorTargetState{ - .format = .bgra8_unorm, - .blend = &wgpu.BlendState{ - .color = .{ .src_factor = .src_alpha, .dst_factor = .one_minus_src_alpha, .operation = .add }, - .alpha = .{ .src_factor = .one, .dst_factor = .one_minus_src_alpha, .operation = .add }, - }, - }; - const pipeline = dev.createRenderPipeline(&wgpu.RenderPipelineDescriptor{ - .layout = pipeline_layout, - .vertex = .{ - .module = sprite_shader, - .entry_point = wgpu.StringView.fromSlice("vs_main"), - .buffer_count = 1, - .buffers = &[_]wgpu.VertexBufferLayout{vertex_layout}, - }, - .fragment = &wgpu.FragmentState{ - .module = sprite_shader, - .entry_point = wgpu.StringView.fromSlice("fs_main"), - .target_count = 1, - .targets = &[_]wgpu.ColorTargetState{color_target}, - }, - .primitive = .{}, - .multisample = .{}, - }) orelse { - log.warn("wgpu sprite pipeline creation failed; sprite rendering disabled", .{}); - return; - }; - - // Create the vertex/index buffers BEFORE publishing `sprite_pipeline`. - // submitFrame gates sprite segments on `sprite_pipeline` alone and then - // unwraps the buffers, so the pipeline must not be visible until both - // buffers exist — otherwise a buffer-creation failure here would leave a - // non-null pipeline with null buffers and panic the first sprite draw. - const vbuf = dev.createBuffer(&wgpu.BufferDescriptor{ - .size = MAX_SPRITE_VERTEX_BYTES, - .usage = wgpu.BufferUsages.vertex | wgpu.BufferUsages.copy_dst, - }) orelse { - log.warn("wgpu sprite vertex buffer creation failed; sprite rendering disabled", .{}); - pipeline.release(); - return; - }; - const ibuf = dev.createBuffer(&wgpu.BufferDescriptor{ - .size = MAX_SPRITE_INDEX_BYTES, - .usage = wgpu.BufferUsages.index | wgpu.BufferUsages.copy_dst, - }) orelse { - log.warn("wgpu sprite index buffer creation failed; sprite rendering disabled", .{}); - vbuf.release(); - pipeline.release(); - return; - }; - - sprite_vertex_buffer = vbuf; - sprite_index_buffer = ibuf; - sprite_pipeline = pipeline; -} - -/// Map an ASTC block size to the matching wgpu `TextureFormat`, or null if it -/// isn't one of the LDR block sizes the enum exposes. We use the Unorm (not -/// sRGB) variants to match the backend's RGBA8 textures (`rgba8_unorm`) and -/// surface (`bgra8_unorm`), so ASTC sprites sample with the same linear-vs-sRGB -/// convention as the PNG/BMP path — no gamma mismatch between formats. -fn astcFormat(block_x: u8, block_y: u8) ?wgpu.TextureFormat { - return switch ((@as(u16, block_x) << 8) | block_y) { - 0x0404 => .astc4x4_unorm, - 0x0504 => .astc5x4_unorm, - 0x0505 => .astc5x5_unorm, - 0x0605 => .astc6x5_unorm, - 0x0606 => .astc6x6_unorm, - 0x0805 => .astc8x5_unorm, - 0x0806 => .astc8x6_unorm, - 0x0808 => .astc8x8_unorm, - 0x0a05 => .astc10x5_unorm, - 0x0a06 => .astc10x6_unorm, - 0x0a08 => .astc10x8_unorm, - 0x0a0a => .astc10x10_unorm, - 0x0c0a => .astc12x10_unorm, - 0x0c0c => .astc12x12_unorm, - else => null, - }; -} - -/// Create + upload an ASTC wgpu texture from a validated compressed slot (#341). -/// The block payload is written verbatim (zero CPU decode); the data layout is -/// the COMPRESSED-block grid, not pixels: each row of blocks is 16 bytes per -/// block, so `bytes_per_row = ceil(w/block_x) * 16` and `rows_per_image = -/// ceil(h/block_y)`. Returns null when ASTC isn't enabled on the device (the -/// adapter lacked the feature) or any GPU step fails. -fn createAstcTexture( - dev: *wgpu.Device, - q: *wgpu.Queue, - c: gfx.CompressedTexture, -) ?*wgpu.Texture { - if (!astc_supported) { - if (!astc_unsupported_warned) { - log.warn("wgpu: ASTC texture skipped — adapter lacks texture_compression_astc", .{}); - astc_unsupported_warned = true; - } - return null; - } - if (c.width == 0 or c.height == 0) return null; - const fmt = astcFormat(c.block_x, c.block_y) orelse return null; - - const tex = dev.createTexture(&wgpu.TextureDescriptor{ - .usage = wgpu.TextureUsages.texture_binding | wgpu.TextureUsages.copy_dst, - .size = .{ .width = c.width, .height = c.height, .depth_or_array_layers = 1 }, - .format = fmt, - }) orelse return null; - - // Compressed data layout: blocks, not texels. One ASTC block = 16 bytes. - const blocks_x = (c.width + c.block_x - 1) / c.block_x; - const blocks_y = (c.height + c.block_y - 1) / c.block_y; - q.writeTexture( - &wgpu.TexelCopyTextureInfo{ .texture = tex, .origin = .{} }, - c.blocks.ptr, - c.blocks.len, - &wgpu.TexelCopyBufferLayout{ - .bytes_per_row = blocks_x * 16, - .rows_per_image = blocks_y, - }, - &wgpu.Extent3D{ .width = c.width, .height = c.height, .depth_or_array_layers = 1 }, - ); - return tex; -} - -/// Lazily create + upload the GPU texture for a gfx texture id, returning its -/// bind group (cached in `gpu_textures`). Runs on the main/GL thread from -/// submitFrame. Returns null if the id is unknown or any GPU step fails. -fn getOrCreateGpuTexture(id: u32) ?*wgpu.BindGroup { - if (id == 0 or id >= MAX_GPU_TEXTURES) return null; - if (gpu_textures[id]) |gt| return gt.bind_group; - - const dev = device orelse return null; - const q = queue orelse return null; - const layout = sprite_bind_group_layout orelse return null; - const sampler = sprite_sampler orelse return null; - - // GPU-compressed (ASTC) slots upload the raw blocks as-is to an ASTC - // texture (#341); everything else is RGBA8. A compressed id never surfaces - // through `getTexturePixels` (it returns null for compressed slots), so the - // two paths are mutually exclusive. - const tex = if (gfx.getCompressedTexture(id)) |c| - createAstcTexture(dev, q, c) orelse return null - else blk: { - const px = gfx.getTexturePixels(id) orelse return null; - if (px.width == 0 or px.height == 0) return null; - - const t = dev.createTexture(&wgpu.TextureDescriptor{ - .usage = wgpu.TextureUsages.texture_binding | wgpu.TextureUsages.copy_dst, - .size = .{ .width = px.width, .height = px.height, .depth_or_array_layers = 1 }, - .format = .rgba8_unorm, - }) orelse return null; - - // Upload RGBA8 rows (4 bytes/pixel, tightly packed — no row padding). - q.writeTexture( - &wgpu.TexelCopyTextureInfo{ .texture = t, .origin = .{} }, - px.pixels.ptr, - px.pixels.len, - &wgpu.TexelCopyBufferLayout{ - .bytes_per_row = px.width * 4, - .rows_per_image = px.height, - }, - &wgpu.Extent3D{ .width = px.width, .height = px.height, .depth_or_array_layers = 1 }, - ); - break :blk t; - }; - - const view = tex.createView(null) orelse { - tex.release(); - return null; - }; - - const bg_entries = [_]wgpu.BindGroupEntry{ - .{ .binding = 1, .texture_view = view }, - .{ .binding = 2, .sampler = sampler }, - }; - const bind_group = dev.createBindGroup(&wgpu.BindGroupDescriptor{ - .layout = layout, - .entry_count = bg_entries.len, - .entries = &bg_entries, - }) orelse { - view.release(); - tex.release(); - return null; - }; - - gpu_textures[id] = .{ .texture = tex, .view = view, .bind_group = bind_group }; - return bind_group; -} - -pub fn initWindow(width_px: i32, height_px: i32, title: [:0]const u8) void { - // `width_px`/`height_px` are the LOGICAL design canvas (project width/height). - // (Params suffixed `_px` so they don't shadow the module-level `width()`/ - // `height()` window-contract decls.) - // Reset per-window contract state so a close→reopen starts clean (else a - // prior `requestQuit` would close the new window immediately + the first - // `frameDuration` would be a huge time-since-old-baseline). Mirrors raylib. - quit_requested = false; - last_frame_time = 0; - gpu_ready = false; // set true by initGpu() below on success; a failed re-init stays not-ready - screen_w = width_px; - screen_h = height_px; - - glfw.init() catch return; - - // WebGPU uses GLFW without an OpenGL context. Hints are set via the - // typed windowHint API (zglfw 0.10 — there is no options-struct - // create overload; that shape belongs to mach-glfw). - glfw.windowHint(.client_api, .no_api); - glfw.windowHint(.visible, !window_hidden); - glfw_window = glfw.createWindow( - @intCast(width_px), - @intCast(height_px), - title, - null, - null, - ) catch return; - - // The window was requested at the LOGICAL width/height. Tell gfx that's - // the design canvas. On a HiDPI/Retina display the backing framebuffer - // is larger (e.g. 2x); seed `screen_w/h` + the gfx physical size from the - // real framebuffer so the swapchain renders at full Retina sharpness and - // the design canvas aspect-fits onto it. wgpu has no template - // `setDesignSize` call (the generated main never invokes it for this - // backend), so window.zig sets it here. Mirrors the bgfx backend. - gfx.setDesignSize(width_px, height_px); - const fb = framebufferSize(); - screen_w = fb[0]; - screen_h = fb[1]; - gfx.setScreenSize(screen_w, screen_h); - - initGpu(); - - const input = @import("input"); - if (glfw_window) |win| { - input.setWindow(win); - } -} - -pub fn closeWindow() void { - // Release lazily-created GPU textures + their views / bind groups. - for (&gpu_textures) |*slot| { - if (slot.*) |gt| { - gt.bind_group.release(); - gt.view.release(); - gt.texture.release(); - slot.* = null; - } - } - if (sprite_pipeline) |p| { - p.release(); - sprite_pipeline = null; - } - if (sprite_vertex_buffer) |b| { - b.release(); - sprite_vertex_buffer = null; - } - if (sprite_index_buffer) |b| { - b.release(); - sprite_index_buffer = null; - } - if (sprite_sampler) |s| { - s.release(); - sprite_sampler = null; - } - if (sprite_bind_group_layout) |l| { - l.release(); - sprite_bind_group_layout = null; - } - - if (glfw_window) |win| win.destroy(); - glfw.terminate(); - glfw_window = null; - // The GPU resources above are released — mark not-ready so a stray - // `endDrawing`/`ensureSurface` after close (or before a re-init's `initGpu`) - // hits the `if (!gpu_ready) return` guard instead of touching freed handles. - gpu_ready = false; -} - -pub fn windowShouldClose() bool { - if (quit_requested) return true; - if (glfw_window) |win| return win.shouldClose(); - return true; -} - -// ── Canonical window contract (labelle-core `assertWindow`) ────────────── -// Additive aliases so the wgpu backend satisfies the canonical window contract -// (width/height/frameDuration/requestQuit) ahead of its out-of-tree extraction -// (#386), mirroring the in-tree raylib/null conformance (#411). The desktop -// template still calls the legacy names + a fixed 0.016 dt, so generated output -// is byte-identical; these exist for the contract guard + manifest-driven -// templates. - -/// Current framebuffer width (physical pixels, HiDPI-aware — `screen_w` is -/// reconciled from `getFramebufferSize()` each frame by `ensureSurface`). -pub fn width() i32 { - return screen_w; -} -/// Current framebuffer height (physical pixels). -pub fn height() i32 { - return screen_h; -} -/// Seconds elapsed since the previous call — the engine's `dt` source. GLFW's -/// monotonic clock; the first call seeds the baseline and returns one nominal -/// 60 Hz step rather than the (large) time-since-glfwInit. -pub fn frameDuration() f64 { - const now = glfw.getTime(); - if (last_frame_time == 0) { - last_frame_time = now; - return 1.0 / 60.0; - } - const dt = now - last_frame_time; - last_frame_time = now; - return dt; -} -/// Ask the window to end the run loop. GLFW has its own close flag too, but a -/// programmatic engine/script quit latches here; `windowShouldClose`/`shouldQuit` -/// OR it in (no behavior change unless something calls this). -pub fn requestQuit() void { - quit_requested = true; -} -/// Canonical alias of `windowShouldClose` (loop-style backends own the -/// `while (!shouldQuit())` loop). Presence signals loop-ownership to the contract. -pub fn shouldQuit() bool { - return windowShouldClose(); -} - -/// Query whether the window is currently fullscreen. Mirrors the bgfx -/// backend: GLFW reports a window bound to a monitor as fullscreen. Returns -/// false before the window exists. -pub fn isFullscreen() bool { - const win = glfw_window orelse return false; - return win.getMonitor() != null; -} - -/// Switch to fullscreen (`on=true`) or windowed (`on=false`). Mirrors the -/// bgfx backend's GLFW approach: GLFW has no toggle primitive, so going -/// fullscreen binds the window to the primary monitor at its current video -/// mode (saving the windowed geometry first); going windowed restores the -/// saved geometry. The resulting PHYSICAL framebuffer change is picked up by -/// `ensureSurface()` on the next `beginDrawing` (which reconfigures the wgpu -/// surface + tells gfx the new physical size), so no resize is done here — -/// this keeps the surface and gfx exactly in step with the live framebuffer -/// rather than guessing the framebuffer from the monitor's logical video -/// mode (wrong on HiDPI). Idempotent — a no-op when already in the requested -/// mode or before the window exists. -pub fn setFullscreen(on: bool) void { - const win = glfw_window orelse return; - const already = win.getMonitor() != null; - if (already == on) return; - if (on) { - // Remember where the window was so we can come back to it. - const pos = win.getPos(); - const size = win.getSize(); - windowed_x = pos[0]; - windowed_y = pos[1]; - windowed_w = size[0]; - windowed_h = size[1]; - const monitor = glfw.getPrimaryMonitor() orelse return; - const mode = glfw.getVideoMode(monitor) catch return; - win.setMonitor(monitor, 0, 0, mode.width, mode.height, mode.refresh_rate); - } else { - win.setMonitor(null, windowed_x, windowed_y, windowed_w, windowed_h, 0); - } -} - -pub fn setTargetFPS(fps: i32) void { - target_fps_val = fps; -} - -pub fn beginDrawing() void { - const input = @import("input"); - input.newFrame(); - // Reconcile the wgpu surface with the current physical framebuffer size - // (DPI move, resize, fullscreen toggle) every frame, so HiDPI changes are - // picked up without a dedicated resize callback. Mirrors bgfx. - ensureSurface(); -} - -/// Drain the gfx frame into the GPU: acquire the surface texture, clear, -/// then replay the ordered draw-segment stream so shapes and sprites -/// composite in strict painter's (submission) order, submit, present. -pub fn endDrawing() void { - if (!gpu_ready) return; - - const frame = gfx.consumeFrame(); - - var surface_texture: wgpu.SurfaceTexture = undefined; - surface.?.getCurrentTexture(&surface_texture); - const texture = surface_texture.texture orelse return; - defer texture.release(); - // Once a swapchain texture has been acquired it must ALWAYS be - // presented — even when an intermediate step below bails — or the - // acquire/present pairing breaks and a transient GPU failure can - // wedge the swapchain permanently. submitFrame's early returns just - // skip the draw; the present still runs. - defer _ = surface.?.present(); - - submitFrame(texture, frame); -} - -/// Upload both vertex/index buffers once, then walk the ordered segment -/// stream. Shapes and sprites keep separate vertex formats + pipelines, so -/// each segment switches the pipeline/buffers for its kind and draws its -/// index range. Draw order now follows per-call submission order via the -/// segment stream — a shape can composite over a sprite within one frame, -/// matching the immediate (raylib) backends. Sprite segments retain the -/// same contiguous same-texture coalescing as before. -fn submitFrame(texture: *wgpu.Texture, frame: gfx.Frame) void { - const view = texture.createView(null) orelse return; - defer view.release(); - - const encoder = device.?.createCommandEncoder(&.{}) orelse return; - defer encoder.release(); - - const color_attachment = wgpu.ColorAttachment{ - .view = view, - .load_op = .clear, - .store_op = .store, - .clear_value = clear_color, - }; - const pass = encoder.beginRenderPass(&wgpu.RenderPassDescriptor{ - .color_attachment_count = 1, - .color_attachments = &[_]wgpu.ColorAttachment{color_attachment}, - }) orelse return; - - // Byte sizes are frame-constant (the buffers are uploaded whole), so - // compute them once here and reuse for both the upload guards and the - // per-segment buffer binds below. - const shape_vbytes = frame.shape_vertices.len * @sizeOf(ShapeVertex); - const shape_ibytes = frame.shape_indices.len * @sizeOf(u32); - const sprite_vbytes = frame.sprite_vertices.len * @sizeOf(SpriteVertex); - const sprite_ibytes = frame.sprite_indices.len * @sizeOf(u32); - - // Upload the shape vertex/index buffers once (guarded by the byte caps). - var shape_uploaded = false; - if (frame.shape_indices.len > 0 and shape_vbytes <= MAX_VERTEX_BYTES and shape_ibytes <= MAX_INDEX_BYTES) { - queue.?.writeBuffer(vertex_buffer.?, 0, frame.shape_vertices.ptr, shape_vbytes); - queue.?.writeBuffer(index_buffer.?, 0, frame.shape_indices.ptr, shape_ibytes); - shape_uploaded = true; - } - - // Upload the sprite vertex/index buffers once (guarded by the byte caps). - var sprite_uploaded = false; - if (frame.sprite_indices.len > 0 and sprite_vbytes <= MAX_SPRITE_VERTEX_BYTES and sprite_ibytes <= MAX_SPRITE_INDEX_BYTES) { - queue.?.writeBuffer(sprite_vertex_buffer.?, 0, frame.sprite_vertices.ptr, sprite_vbytes); - queue.?.writeBuffer(sprite_index_buffer.?, 0, frame.sprite_indices.ptr, sprite_ibytes); - sprite_uploaded = true; - } - - // Replay segments in submission order, switching pipeline per kind. - for (frame.segments) |seg| { - switch (seg.kind) { - .shape => { - if (!shape_uploaded) continue; - const sp = shape_pipeline orelse continue; - pass.setPipeline(sp); - pass.setVertexBuffer(0, vertex_buffer.?, 0, shape_vbytes); - pass.setIndexBuffer(index_buffer.?, .uint32, 0, shape_ibytes); - pass.drawIndexed(seg.index_count, 1, seg.index_start, 0, 0); - }, - .sprite => { - if (!sprite_uploaded) continue; - const sp = sprite_pipeline orelse continue; - pass.setPipeline(sp); - pass.setVertexBuffer(0, sprite_vertex_buffer.?, 0, sprite_vbytes); - pass.setIndexBuffer(sprite_index_buffer.?, .uint32, 0, sprite_ibytes); - drawSpriteRange(pass, frame.sprite_texture_ids, seg.quad_start, seg.quad_count); - }, - } - } - - pass.end(); - pass.release(); - - const command = encoder.finish(null) orelse return; - defer command.release(); - queue.?.submit(&[_]*const wgpu.CommandBuffer{command}); -} - -/// Draw the quads in `[quad_start, quad_start+quad_count)` of an -/// already-bound sprite pipeline/buffers, issuing one drawIndexed per -/// contiguous run of quads that share a texture (binding that texture's -/// bind group). Each quad is 4 verts / 6 indices; `first_index = quad*6`. -/// Quads whose texture failed to upload are skipped so the rest still -/// renders. Assumes the sprite pipeline + vertex/index buffers are already -/// set by the caller for this segment. -fn drawSpriteRange( - pass: *wgpu.RenderPassEncoder, - texture_ids: []const u32, - quad_start: u32, - quad_count: u32, -) void { - const start: usize = quad_start; - const end: usize = start + quad_count; - if (end > texture_ids.len) return; - - var quad: usize = start; - while (quad < end) { - const tex_id = texture_ids[quad]; - var run_end = quad + 1; - while (run_end < end and texture_ids[run_end] == tex_id) run_end += 1; - - if (getOrCreateGpuTexture(tex_id)) |bind_group| { - pass.setBindGroup(0, bind_group, 0, null); - const index_count: u32 = @intCast((run_end - quad) * 6); - const first_index: u32 = @intCast(quad * 6); - pass.drawIndexed(index_count, 1, first_index, 0, 0); - } - quad = run_end; - } -} - -pub fn clearBackground(r: u8, g: u8, b: u8, a: u8) void { - clear_color = .{ - .r = @as(f64, @floatFromInt(r)) / 255.0, - .g = @as(f64, @floatFromInt(g)) / 255.0, - .b = @as(f64, @floatFromInt(b)) / 255.0, - .a = @as(f64, @floatFromInt(a)) / 255.0, - }; -} - -pub fn drawText(text: [:0]const u8, x: i32, y: i32, font_size: i32, r: u8, g: u8, b: u8, a: u8) void { - // Route through gfx's bitmap-font glyph rects so HUD text lands in the - // same shape batch the submitter drains. - gfx.drawText(text, @floatFromInt(x), @floatFromInt(y), @floatFromInt(font_size), .{ .r = r, .g = g, .b = b, .a = a }); -} diff --git a/backends/wgpu/templates/desktop.txt b/backends/wgpu/templates/desktop.txt deleted file mode 100644 index 853f6824..00000000 --- a/backends/wgpu/templates/desktop.txt +++ /dev/null @@ -1,49 +0,0 @@ -const screen_w: u32 = {{width}}; -const screen_h: u32 = {{height}}; -const screen_title = "{{title}}"; -const target_fps: u32 = {{fps}}; - -{{module_vars}}pub fn main() !void { - var gpa = std.heap.DebugAllocator(.{}).init; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - -{{hidden_setup}} window.initWindow(screen_w, screen_h, screen_title); - defer window.closeWindow(); - window.setTargetFPS(target_fps); - -{{hooks_init_block}} - var g = AssembledGame.init(allocator); - defer g.deinit(); - - g.setHooks(&hooks); - - g.setScreenHeight(@as(f32, @floatFromInt(screen_h))); - -{{preview_setup}}{{setup_code}} - while (!window.windowShouldClose()) { - const dt: f32 = 0.016; - // Apply a pending fullscreen/windowed switch requested via - // `game.setFullscreen`/`toggleFullscreen`. The engine owns the - // desired flag; the backend owns the platform call. Gated on BOTH - // the backend exposing `setFullscreen` and the engine exposing the - // drain, so the template stays compatible with backends/engines - // that predate the API (older example projects still build). - if (comptime @hasDecl(window, "setFullscreen") and @hasDecl(@TypeOf(g), "takeFullscreenRequest")) { - if (g.takeFullscreenRequest()) |on| window.setFullscreen(on); - } - // Vsync: same engine-owned-flag / backend-applies split as fullscreen. - // Folds away on backends without `setVsync` or engines predating the API. - if (comptime @hasDecl(window, "setVsync") and @hasDecl(@TypeOf(g), "takeVsyncRequest")) { - if (g.takeVsyncRequest()) |on| window.setVsync(on); - } -{{preview_heartbeat}}{{tick_code}} g.tick(dt); - - window.beginDrawing(); - window.clearBackground(245, 245, 245, 255); - g.render(); - g.renderGizmos(); -{{gui_draw_code}} window.drawText(screen_title, 10, 10, 20, 80, 80, 80, 255); - window.endDrawing(); - } -} diff --git a/examples/wgpu/.gitignore b/examples/wgpu/.gitignore deleted file mode 100644 index 7781e868..00000000 --- a/examples/wgpu/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.labelle/ diff --git a/examples/wgpu/components b/examples/wgpu/components deleted file mode 120000 index 02e0b369..00000000 --- a/examples/wgpu/components +++ /dev/null @@ -1 +0,0 @@ -../../../game/components \ No newline at end of file diff --git a/examples/wgpu/gizmos b/examples/wgpu/gizmos deleted file mode 120000 index 17d5c395..00000000 --- a/examples/wgpu/gizmos +++ /dev/null @@ -1 +0,0 @@ -../../../game/gizmos \ No newline at end of file diff --git a/examples/wgpu/hooks b/examples/wgpu/hooks deleted file mode 120000 index e3c0fbc6..00000000 --- a/examples/wgpu/hooks +++ /dev/null @@ -1 +0,0 @@ -../../../game/hooks \ No newline at end of file diff --git a/examples/wgpu/prefabs b/examples/wgpu/prefabs deleted file mode 120000 index e9130f45..00000000 --- a/examples/wgpu/prefabs +++ /dev/null @@ -1 +0,0 @@ -../../../game/prefabs \ No newline at end of file diff --git a/examples/wgpu/project.labelle b/examples/wgpu/project.labelle deleted file mode 100644 index 9a1391d1..00000000 --- a/examples/wgpu/project.labelle +++ /dev/null @@ -1,20 +0,0 @@ -.{ - .name = "example_wgpu", - .title = "LaBelle v2 — WebGPU", - .backend = .wgpu, - .y_axis = .up, - .ecs = .mock, - .core_version = "local:../../../labelle-core", - .engine_version = "local:../../../labelle-engine", - .gfx_version = "local:../../../labelle-gfx", - .labelle_version = "local:../../../labelle-cli", - .assembler_version = "local:../../", - .states = .{ "playing" }, - .layers = .{ - .{ .name = "far_background", .order = 0, .space = .screen }, - .{ .name = "terrain", .order = 1, .space = .world }, - .{ .name = "characters", .order = 2, .space = .world }, - .{ .name = "effects", .order = 3, .space = .world }, - .{ .name = "hud", .order = 4, .space = .screen }, - }, -} diff --git a/examples/wgpu/scenes/main.jsonc b/examples/wgpu/scenes/main.jsonc deleted file mode 100644 index 9e8823f5..00000000 --- a/examples/wgpu/scenes/main.jsonc +++ /dev/null @@ -1,31 +0,0 @@ -{ - // Main scene — example game - "name": "main", - "entities": [ - // Player entity - { - "name": "player", - "components": { - "Position": { "x": 400, "y": 300 }, - "Shape": { - "shape": { "rectangle": { "width": 32, "height": 32 } }, - "color": { "r": 60, "g": 120, "b": 220, "a": 255 }, - "layer": "characters", - "z_index": 5 - }, - "Velocity": { "x": 0, "y": 0 } - } - }, - // Title label - { - "components": { - "Position": { "x": 400, "y": 50 }, - "Text": { - "text": "LaBelle Example", - "size": 24, - "color": { "r": 255, "g": 255, "b": 255, "a": 255 } - } - } - } - ] -} diff --git a/test/build_zig_tests.zig b/test/build_zig_tests.zig index 908eb75f..2b7e134e 100644 --- a/test/build_zig_tests.zig +++ b/test/build_zig_tests.zig @@ -190,25 +190,13 @@ pub const BUILD_ZIG = struct { }, .{})); } - test "links wgpu glfw artifact" { - // wgpu is now an extracted (external) backend (#386 Phase 6c). `.backend - // = .wgpu` resolves to the labelle-wgpu package; point it at the in-tree - // copy via a local path + project_dir so the desktop manifest splice - // resolves its backend.manifest.zon (the fragment emits the same - // labelle_wgpu / glfw_artifact the old enum `backend_wgpu` section did — - // byte-identical, verified in #426). project_dir is the assembler repo - // root (the test runner's cwd). - const build_zig = try generate.generateBuildZig(std.testing.allocator, .{ - .name = "test-game", - .backend = .wgpu, - .backend_package = .{ .name = "wgpu", .repo = "local:backends/wgpu" }, - .ecs = .mock, - }, .{ .project_dir = "." }); - defer std.testing.allocator.free(build_zig); - - try std.testing.expect(std.mem.indexOf(u8, build_zig, "labelle_wgpu") != null); - try std.testing.expect(std.mem.indexOf(u8, build_zig, "glfw_artifact") != null); - } + // NOTE: wgpu's desktop backend-dep codegen (the labelle_wgpu / glfw_artifact + // links) is no longer unit-tested here — wgpu is extracted out-of-tree + // (labelle-wgpu), so its in-tree `backends/wgpu` is gone and there's no local + // package for a unit test to resolve. That coverage now lives in labelle-wgpu's + // own CI (its assembler-integration job generates a wgpu project through the + // assembler + asserts the Foundation/QuartzCore/Metal framework links) + the + // manifest-splice tests + the examples-integration `external-null` step. test "null backend wires modules without artifact link" { const build_zig = try generate.generateBuildZig(std.testing.allocator, .{ diff --git a/test/preview_mode_tests.zig b/test/preview_mode_tests.zig index 3543347b..b8c1b3e5 100644 --- a/test/preview_mode_tests.zig +++ b/test/preview_mode_tests.zig @@ -933,10 +933,9 @@ pub const PREVIEW_MODE = struct { const cases = [_]Case{ .{ .backend = .raylib, .template = "backends/raylib/templates/desktop.txt" }, .{ .backend = .sdl, .template = "backends/sdl/templates/desktop.txt" }, - // bgfx is extracted out-of-tree (labelle-bgfx) — its templates live - // there + are covered by its own CI, so it's no longer in this - // in-tree-template regression list. - .{ .backend = .wgpu, .template = "backends/wgpu/templates/desktop.txt" }, + // bgfx + wgpu are extracted out-of-tree (labelle-bgfx / labelle-wgpu) + // — their templates live there + are covered by their own CI, so + // they're no longer in this in-tree-template regression list. .{ .backend = .sokol, .template = "backends/sokol/templates/desktop.txt" }, .{ .backend = .null, .template = "backends/null/templates/headless.txt" }, };