A technical proof of concept exploring the rebuilding of a classic rendering and input engine entirely on Apple-native frameworks.
About this repository — a Rust learning exercise. This builds on Metal-Quake and extends it as another exercise to better learn Rust development: the engine's game logic (the server, physics, the QuakeC VM, the client, model/world code) is being incrementally ported from C to Rust and validated bit-for-bit against the C original by a differential test harness, while the Metal/Apple rendering layer described below stays intact.
metal-quake.mp4
Metal Quake maps id Software's original 1996 Quake engine onto native Apple technologies. No SDL. No OpenGL. The C engine and Metal renderer carry no third-party dependencies; the Rust port adds one — jemalloc as the global allocator for the engine build.
This is an active work-in-progress that acts as a testbed for Apple platform APIs inside of an existing C codebase.
- Rendering: The Metal path tracer is the sole world renderer — the 1996 software rasterizer has been removed. BSP geometry and dynamic lights are path-traced in Metal, and the path tracer writes an RT depth texture (
_pRTDepthTexture). Particles and sprites render in dedicated Metal passes (textured/colored billboards) depth-tested against that RT depth texture so they occlude against the ray-traced world correctly. The legacy 8-bitvid.buffernow carries only the 2D overlay — HUD, console, menu — drawn by the Rustdraw/sbar/console/screenmodules, and is composited over the path tracer by chroma-key. A hardware Mesh Shader (MTL::MeshRenderPipelineDescriptor) path exists for traditional rasterization of high-poly clusters. - Parallel Encoding: The main render loop utilizes
dispatch_applyto split the encoding of heavy GPU compute tasks (Raytracing, Denoising) and Render tasks (Compositing, UI) across multiple Apple Silicon P-cores concurrently. Thread-safe GPU synchronization is handled viaMTLSharedEvent. - Input Robustness: Mouse look exclusively relies on raw
CGEventdeltas and programmatic cursor warping, forcefully establishing window focus to survive system-level event hijacking (such asCmd+Tabor macOS screenshot overlays). - Post-Processing: 12-stage GPU fragment shader pipeline with CRT scanlines, Liquid Glass HUD, SSAO, EDR/HDR, ACES tonemapping, depth of field, bloom, and underwater warp — all hot-toggleable from the in-game Video Options menu.
- GPU Denoising:
MPSImageGaussianBlur(with bilateral à-trous fallback) runs entirely on the GPU against the RT output texture — no CPU round-trip. A trained Real-ESRGAN model is not yet loaded; theMPSGraphupscaler path currently performs bilinear 4× as a stand-in, ready to swap in trained weights without touching call sites.
Alongside the Metal renderer, the engine's game logic has been incrementally ported from C to Rust — a parallel effort to learn Rust on a real, demanding codebase. The engine is now almost entirely Rust: 57 modules in rust/quakecore compiled into libquakecore.a and linked into the same binary, validated bit-for-bit against the frozen C original by a differential test harness (27 differential suites, fuzzed and compared on .to_bits() equality). The C version of a module is retired only after its Rust replacement passes byte-exact parity. Only 2 compiled core .c files remain — console.c and host.c, the irreducible shims (the variadic printf families and setjmp landing pad that stable Rust can't express, forwarding to #[no_mangle] Rust helpers). The entire render front-end is Rust, ported parity-first: r_light (lighting), r_part (the particle system, rand-stream exact), r_sprite (frame groups + the 5-type billboard math), r_sky (the live half; the dead software compositors were deleted), r_efrag (static-entity fragment linking), and finally r_main+r_misc (frame setup, PVS marking, R_RenderView orchestration, and the shared render globals vid_metal.cpp links against) — each slice proven bit-exact against its frozen C over synthetic inputs before retirement. The platform LAN-driver table (net_macos.c) moved into Rust as #[no_mangle] statics — it was pure data whose function pointers already resolved to Rust.
flowchart LR
subgraph Spine[Compiled C + Apple layer · native by design]
Host[host.c + console.c shims<br/>variadic printf · setjmp landing pad]
Apple[Metal RT · PostFX · CoreAudio · net_apple UDP driver]
end
subgraph Core[libquakecore.a — Rust · 57 modules]
Sim[Server + physics<br/>sv_main / phys / move / user · world]
Client[Client<br/>cl_main / parse / input / demo / tent]
VM[QuakeC VM<br/>pr_exec · pr_edict · pr_cmds]
Parse[Parsers / utils<br/>qcommon · model · wad · crc · math]
Snd[Sound<br/>snd_mix mixer · snd_mem loader · snd_dma driver]
Rfe[Render front-end<br/>r_main · r_misc · r_light · r_part · r_sprite · r_sky · r_efrag]
Infra[cmd · cvar · host · keys · console · draw · screen · view · sbar · menu · zone · chase · net_main/dgrm/loop/vcr]
end
subgraph Diff[Differential harness · rust/difftest]
Cref[frozen C reference]
Cmp[bit-compare every ported fn]
end
Host --> Sim & Client & VM & Snd & Infra & Rfe
Rfe -. camera/PVS · stages particles/sprites + 2D overlay .-> Apple
Apple -. calls .-> Snd & Sim
Core -. each fn .-> Cref --> Cmp
Where it stands (the PLAN.md phases):
| Phase | Scope | Status |
|---|---|---|
| 0 — Scaffolding | build seam · bindgen/cbindgen FFI · diff harness |
✅ |
| 1 — Pure leaves | mathlib, crc |
✅ |
| 2 — Untrusted parsers | qcommon MSG/SZ, cl_parse, model, wad |
✅ |
| 3 — QuakeC VM | pr_exec, pr_edict, pr_cmds |
✅ |
| 4 — Simulation pivot | world, sv_move, sv_phys + the edict arena moved into Rust ownership |
✅ |
| beyond — spine + client + net + sound | cmd, cvar, host, keys, console, draw, sbar, screen, view, menu; the client (cl_main/input/parse/demo/tent); the net drivers (net_main/dgrm/loop/vcr); the whole sound system (snd_dma/mix/mem) |
✅ |
| renderer — rasterizer kill | software rasterizer deleted; world rendered solely by the Metal path tracer; particles + sprites moved to dedicated Metal passes | ✅ |
What the port unlocks (per PLAN.md): a memory-safe engine — malicious .bsp/.mdl/.wav/packets and untrusted QuakeC become clean, bounds-checked errors instead of memory corruption; a fearlessly parallel simulation — the edict arena is now Rust-owned, and SV_Move collision scales ~14× across cores, bit-identical to single-threaded (demo-safe — see PIVOT.md); and a sandboxed QuakeC VM. The Metal/Apple layer below stays native.
Features are categorized honestly:
- Shipped — Compiled, linked, and actively running in the game loop every frame
- In Motion — Built but disabled for stability, or partially integrated
- Planned — Design intent only, not compiled into binary
| Layer | Apple Framework | Status | What It Does |
|---|---|---|---|
| Rendering | Metal | Shipped | Metal device, texture pipeline, path-traced world + Metal particle/sprite passes, chroma-key compositor over a 2D overlay |
| Ray Tracing | Metal RT | Shipped | BLAS from BSP geometry, RT intersection, dynamic GI + emissive surfaces |
| Post-Processing | Metal Fragment | Shipped | CRT scanlines, Liquid Glass HUD, SSAO, EDR/HDR, ACES tonemapping, DoF, bloom |
| Upscaling | MetalFX | Shipped | Temporal (640→1280, Halton jitter + RT depth/motion) — presented in the composite |
| Legacy Audio | Core Audio | Shipped | Lock-free ring buffer, async pull model |
| Spatial Audio | PHASE | Shipped | Physically-modeled sound with BSP triangle-mesh occluder, per-environment distance model (water/slime/lava/air), raw PCM float32 conversion |
| Mouse Input | CGEvent | Shipped | Raw delta input, continuous cursor warping, robust focus survival |
| Keyboard | Carbon / NSEvent | Shipped | Full key mapping |
| Controllers | GameController | Shipped | DualSense + Xbox — sticks, triggers, D-pad, weapon-aware adaptive triggers (reprogrammed on weapon switch), optional gyro aim, low-battery warnings |
| Threading | GCD | Shipped | dispatch_apply parallel command buffer encoding and BSP leaf marking |
| Networking | Network.framework | Shipped | UDP driver with NWConnection/NWListener, Multipath UDP, per-packet sender endpoint extraction, pending-connection queue for UDP_CheckNewConnections |
| UI | SwiftUI | Shipped | NSPanel launcher overlay, full settings bridge to engine cvars |
| Settings Sync | UserDefaults | Shipped | Cross-syncs @AppStorage values to engine variables; all 28 struct fields round-trip to id1/metal_quake.cfg on engine start/stop |
| SVGF Denoise | Compute | Shipped | r_svgf cvar: mode 1 temporal reprojection, mode 2 full variance-guided (RG16F moments + R16F variance + dedicated svgfVariance kernel) |
| Frame Interpolation | MetalFX | In Motion | MTLFXFrameInterpolator encode pass runs (r_frameinterp); the synthesized frame is not yet presented |
| BLAS Split | Metal RT | Shipped | r_rt_split_blas cvar: world BLAS cached per map + entity BLAS per frame + 2-instance IAS with per-instance metadata offsets for correct TriTexInfo lookup |
| ReSTIR DI | Compute | Shipped | r_restir cvar: CPU builds emissive-triangle list from 8×8 atlas-grid sampling; shader does 4-candidate RIS plus temporal (motion-reprojected) and spatial (neighbor) reservoir reuse persisted in ping-pong device buffers — true ReSTIR, converging with one shadow ray per pixel |
| Argument Buffers | Metal | Shipped | 6 RT device pointers (vertices, indices, triTexInfos, dynLights, instanceOffsets, emissiveTris) wrapped in a single MTLArgumentEncoder buffer at slot 5 with per-resource useResource: annotation |
| PostFX Function Constants | Metal | Shipped | 5 [[function_constant]] toggles (SSAO, CRT, Liquid Glass, chromatic aberration, high-contrast HUD); a settings-mask-keyed pipeline cache builds + selects the specialized variant each frame so disabled stages are dead-code-eliminated, not runtime-branched |
| GPU Denoising | MPS / MPSGraph | Shipped | Bilateral à-trous (primary) + MPSImageGaussianBlur (opt-in via MQ_MPS_DENOISE=1 env) |
| CoreML Upscaler | MLModel + ANE | Shipped | MLModel.modelWithContentsOfURL: loads MQ_RealESRGAN.mlmodelc and runs on ANE at 320×240 baked input; MPSGraph (bilinear + unsharp conv) fallback for other sizes |
| MetalFX Temporal | MetalFX | Shipped | 640×480 → 1280×960 upscale with Halton(2,3) jitter + RT depth + motion vectors |
| MTLResidencySet | Metal | Shipped | macOS 15+ pins atlas + meshlet buffers as resident via MQ_Residency.m shim |
| Mesh Shaders | Metal 3.1 | Shipped | High-poly BSP clustering with Object-Shader frustum culling + distance-based LOD |
| BLAS Refit | Metal RT | Shipped | refitAccelerationStructure on stable-topology frames (3-5× faster than rebuild) |
| Shader Caching | MTLBinaryArchive | Shipped | Zero-stutter implicit caching; serialized to disk on VID_Shutdown |
| Crash Reporting | MetricKit | Shipped | MXMetricManager subscriber writes JSON payloads to ~/Library/Application Support/MetalQuake/ |
| SharePlay | GroupActivities | Shipped | QuakeGroupActivity: GroupActivity with MQSharePlayManager observer; auto-connect on incoming session joins |
| Game Center | GameKit | Shipped | Authentication sheet presented on the game window; achievement + leaderboard submission on intermission |
| OS Integration | Game Mode | Shipped | Bundled .app with LSApplicationCategoryType + NSGameMode for doubled Bluetooth polling + GPU priority |
Measure with the bench harness — it forces fullscreen, which matters:
windowed presents go through the WindowServer compositor, which paces
nextDrawable to the panel's adaptive refresh (~100 Hz), so every
windowed timedemo reads ~99 fps regardless of engine cost.
./tests/bench_timedemo.sh # 3 runs each of demo1/2/3
MQ_BENCH_ARGS="+r_rt_split_blas 0" ./tests/bench_timedemo.sh 5 demo1 # A/B a cvarRepresentative fullscreen results (M4, 640×480 internal RT → MetalFX
temporal upscale, all effects active): ~150-175 fps across
demo1/demo2/demo3. The frame is presentation/GPU-latency bound — engine
CPU cost per frame is ~0.3 ms (r_gpuprofile 1 prints the full split:
BuildRTX phases, semaphore/drawable waits, CPU encode, and true GPU time
per command buffer).
Per-frame acceleration-structure work is the headline economy: the world
BLAS is cached per map (r_rt_split_blas 1, the default), only the small
entity BLAS refits per frame, and no CPU wait exists anywhere in the
build path — the legacy whole-scene rebuild cost ~6.5-6.9 ms/frame of the
same budget.
Note
Benchmarks reflect the engine at full tilt: Path-Traced GI, Neural Denoising, MetalFX Temporal Upscaling, ACES Tonemapping, CRT Scanlines, and Parallel Command Encoding all active.
The Metal path tracer is the sole world renderer. R_RenderView gathers
particles and sprites for their dedicated Metal passes and draws the 2D
overlay (HUD / console / menu) into the 8-bit vid.buffer; a Metal
compute kernel path-traces the world. A fragment shader composites the
two by chroma-key (palette index 254 reveals the path tracer) and runs
the 12-stage PostFX pipeline.
flowchart TB
subgraph Launcher[SwiftUI Launcher]
MQ[MQSettingsStore AppStorage]
end
subgraph Engine[Engine core — Rust libquakecore.a + thin C shims]
Host[Host_Frame]
Physics[SV_Physics · Rust-owned edicts]
RenderView[R_RenderView gathers particles/sprites + 2D overlay → vid.buffer]
end
subgraph RT[Metal RT path]
Build[BuildRTXWorld]
IAS[Instance AS unified / split]
Kernel[raytraceMain compute]
SVGF[SVGF reproject + variance]
Bilateral[Bilateral atrous]
MFX[MetalFX Temporal + Frame Interp]
end
subgraph Passes[Metal billboard passes]
Particles[Particle pass — depth-tested vs RT depth]
Sprites[Sprite pass — depth-tested vs RT depth]
end
subgraph Composite[Composite + PostFX fragment]
Chroma[Chromakey index 254]
PostFX[12 stage PostFX]
end
subgraph Audio
CA[Core Audio ring]
PHASE[PHASE BSP occluder]
Haptics[CHHaptic + DualSense]
end
subgraph Input
NSE[NSEvent + FlagsChanged]
CG[CGEvent mouse deltas]
Ctrl[GCController + gyro]
end
MQ -->|bridge sync| Host
Host --> Physics --> RenderView
Host --> Build --> IAS --> Kernel --> SVGF --> Bilateral
RenderView --> Particles & Sprites
Bilateral --> Particles --> Sprites --> MFX
RenderView -->|2D overlay| Chroma
MFX --> Chroma
Chroma --> PostFX --> Drawable[present drawable]
Host --> CA
Host --> PHASE
Host --> Haptics
NSE --> Host
CG --> Host
Ctrl --> Host
Deep dives: TECHNICAL.md covers acceleration-structure topology,
SVGF data flow, argument buffers, ReSTIR DI, PHASE BSP occluder, the
bridge/launcher design, and the complete cvar reference — each section
has its own mermaid diagram.
./build.sh
open build/Quake.appRequirements:
- Apple Silicon Mac (M1+)
- macOS 14.0+ (Sonoma/Sequoia/Tahoe)
- Xcode.app with Metal Toolchain:
xcodebuild -downloadComponent MetalToolchain id1/pak0.pak(user-provided — no game assets included)
Beyond Quake's classic console variables, the Apple Silicon port adds:
| Cvar | Default | Description |
|---|---|---|
vid_rtx |
1 | Toggle path-traced world |
sv_parallel |
1 | 0 serial · 1 hybrid parallel sim (default; gated by tests/verify_sim.sh) · 2 hybrid + per-frame serial-vs-parallel self-check |
sv_statehash |
0 | Per-frame deterministic sim checksum (STATEHASH lines); 2 adds per-entity EDUMP |
sv_simframes |
0 | Self-terminate a scripted run after N hashed frames (verify-sim harness) |
sv_debris |
0 | QC bounce entities coming to rest leave permanent engine-owned debris chunks, simulated by the parallel set |
sv_simprof |
0 | Once-a-second SV_Physics ms/frame readout |
r_svgf |
0 | 0 off · 1 temporal reprojection · 2 full variance-guided SVGF |
r_frameinterp |
0 | MetalFX Frame Interpolation encode pass (macOS 15+) |
r_rt_split_blas |
1 | Split world (cached per map) + entity BLAS with 2-instance IAS + offset indirection; 0 = legacy whole-scene BLAS |
r_gpuprofile |
0 | 60-frame avg perf split: BuildRTX phases + semaphore/drawable/encode/GPU times |
r_restir |
0 | ReSTIR DI reservoir sampling over emissive world triangles |
vid_vsync |
0 | CAMetalLayer.displaySyncEnabled |
vid_fullscreen |
0 | Toggle native fullscreen via -[NSWindow toggleFullScreen:] |
joy_gyro_enabled |
0 | Add controller gyro deltas to view angles |
joy_gyro_yaw |
30.0 | Gyro yaw sensitivity |
joy_gyro_pitch |
20.0 | Gyro pitch sensitivity |
joy_sensitivity |
1.0 | Global stick / gyro sensitivity scale |
showfps |
0 | Top-right FPS overlay (0.25 s smoothing window) |
Console commands: mq_info dumps hardware + feature state,
dumpcvars lists every registered cvar.
Environment: MQ_MPS_DENOISE=1 opts into the GPU Gaussian denoise
path over the default bilateral à-trous. MQ_SIGN_IDENTITY="Developer ID Application: …" selects a real code-signing identity instead of
the ad-hoc - that build.sh uses by default.
The in-game Video Options menu exposes the main toggles live; the
SwiftUI launcher mirrors the full set via @AppStorage and writes
id1/metal_quake.cfg on Apply.
Caution
This repository contains no proprietary game assets. You must provide your own id1/pak0.pak.
Metal_Quake/
├── src/core/ # id Tech 1 engine core — only 2 compiled .c files left (the console + host shims); the rest stay on disk as the diff-test reference
├── rust/ # Rust port — links into the same binary as libquakecore.a
│ ├── quakecore/ # ~51 ported engine modules (spine · sim · client · QuakeC VM · net · sound · parsers)
│ ├── difftest/ # differential parity harness — frozen C reference vs Rust
│ └── bench/ # parallel-sim benchmark (SV_Move ~14× across cores)
├── src/macos/ # Apple platform layer + variadic/setjmp shims (32 files)
│ ├── sys_macos.m # macOS system layer + event loop
│ ├── vid_metal.cpp # Metal rendering, PostFX pipeline, 12-item menu, GCD parallel dispatch
│ ├── mq_particles.c # CPU→GPU bridge for the Metal particle pass
│ ├── mq_sprites.c # CPU→GPU bridge for the Metal sprite pass
│ ├── d_stubs.c # no-op D_* device layer (software raster deleted)
│ ├── com_va.c / pr_runerror.c # variadic vsprintf shims (not stable-Rust expressible)
│ ├── host_native.c # platform-glue host functions forwarding to Rust
│ ├── rt_shader.metal # RT intersection + GI compute kernel
│ ├── Metal_Renderer_Main.cpp # Settings init/save/load lifecycle
│ ├── Metal_Settings.h # MetalQuakeSettings struct definition
│ ├── MQ_MeshShaders.metal # Object/mesh/fragment pipeline (M3+)
│ ├── MQ_LiquidGlass.metal # Refractive glass compositor
│ ├── MQ_PHASE_Audio.m # PHASE spatial audio with dynamic float32 buffers
│ ├── MQ_CoreML.m # MPSGraph denoiser + upscaler
│ ├── MQ_Ecosystem.m # Game Center + SharePlay + Accessibility
│ ├── MetalQuakeLauncher.swift # SwiftUI launcher
│ ├── net_apple.cpp # Network.framework UDP driver (Multipath)
│ ├── snd_coreaudio.cpp # Core Audio ring buffer
│ ├── in_gamecontroller.mm # GameController + DualSense Adaptive Triggers + Core Haptics
│ ├── GCD_Tasks.m # Parallel dispatch utilities
│ └── Sys_Tahoe_Input.mm # Unified input architecture
├── metal-cpp/ # Vendored Apple metal-cpp headers
├── build.sh # Single-command build (clang, arm64) → build/Quake.app
└── id1/ # Game data (user-provided)
Full gamepad support for DualSense, Xbox, and MFi controllers:
| Button | Action |
|---|---|
| Right Trigger | Fire (Adaptive Resistance on DualSense) |
| Left Trigger / A | Jump |
| Y / Right Bumper | Next weapon |
| Left Bumper | Previous weapon |
| B | Swim down |
| X | Use / Interact |
| Menu | Pause (Escape) |
| Left Stick | Move |
| Right Stick | Look |
| D-pad | Move (alternate) |
Every weapon has a distinct haptic profile and adaptive trigger resistance tuned for its feel:
| Weapon | Trigger Pull (DualSense) | Haptic Rumble Feel |
|---|---|---|
| Axe | None | Sharp thud |
| Shotgun | Heavy pull, sudden break | Medium punch |
| Super Shotgun | Heavy pull, sudden break | Heavy double-tap |
| Nailgun | Continuous machine-gun vibration | Light rapid |
| Super Nailgun | Continuous machine-gun vibration | Medium rapid |
| Grenade Launcher | Max resistance | Deep thump |
| Rocket Launcher | Max resistance | Heavy kick |
| Lightning Gun | Smooth, constant resistance | Sustained buzz |
Damage feedback scales proportionally. Nearby explosions produce distance-attenuated low-frequency feedback.
GPLv2 — Fork of the Quake source code originally released by id Software.
Quake is a registered trademark of id Software / ZeniMax Media / Microsoft.