Personal project · Metal + SwiftUI + C++

Hearth

A real-time 3D engine for iOS and macOS. C++ renderer talking to Metal directly through metal-cpp, with a SwiftUI editor shell and a hand-rolled ECS.

Swift · SwiftUI · C++ · Metal · 2025 — present · iOS 17+ · macOS 14+
Chrome reference sphere reflecting the studio HDR environment, rendered in real time with split-sum IBL and screen-space reflections.

A renderer worth porting, behind a Swift app worth shipping.

Hearth is the personal sandbox where the iOS / Metal / C++-bridging pattern I ship at work gets pushed past production constraints. The renderer is C++ in the warmforge:: namespace, talking to Metal directly through metal-cpp. The editor shell is SwiftUI. The seam between them is one Obj-C++ bridge file — Swift hands the renderer integer handles and reads @Published stats back; Swift never sees Metal directly.

That layout is deliberate. The renderer should be the easy part to port if I ever want a Vulkan or Windows backend. Keeping it C++ behind a small bridge means a future backend can sit beside WarmForge in Rendering/ and present the same RendererBridge API to Swift.

Forward+ clustered, hybrid RT, full PBR.

The frame is Forward+ clustered shading on a 16×9×24 view-frustum grid. A compute pre-pass bins point lights into the clusters they touch. The PBR fragment then iterates a per-cluster list capped at 32, plus a separate short loop over a dedicated globals buffer for directional and ambient lights. Nothing scans all lights per fragment looking for non-point types. Forward over deferred is the right call on Apple-silicon TBDR — fat G-buffers fight the tile-memory model, and forward keeps MSAA, transparency, and material variation cheap.

Environment lighting is split-sum IBL. A Radiance .hdr equirectangular gets projected into a 1024² RGBA16F cubemap on first frame, then GGX-importance-sampled into a mip chain (Karis 2014). The PBR ambient branch reads the chain via level(roughness * maxMip) for roughness-aware specular. A 2D BRDF LUT handles the Fresnel-at-NdotV term, and Fdez-Aguera multi-scatter compensation recovers the energy GGX loses at high roughness so brushed gold and copper land at the right luminance.

Shadows are hybrid ray-traced. Per-mesh BLAS built lazily, one encoder per build so concurrent builds don't race on the shared scratch buffer. TLAS rebuilt every frame from the active draw list with triple-buffered instance descriptors. A compute kernel reconstructs world position from depth and casts a shadow ray per pixel against the TLAS. The 8-tap PCF disk is rotated per pixel by interleaved-gradient noise so penumbras don't band. Capability-gated on device->supportsRaytracing(), so Intel Macs render correctly with the toggle off.

On top of that: screen-space reflections with Fresnel weighting by metallic (chrome reflects scene geometry at full strength, dielectrics blend in at ~4%), volumetric fog with a Henyey-Greenstein phase function and IQ-style 3D noise, bloom and a Stephen Hill ACES tonemap. Per-material spectral F₀ lets gold, copper, and aluminum get their actual Fresnel response instead of the legacy mix(0.04, albedo, metallic) approximation.

Three frames from the live app.

Default Hearth scene: wooden tabletop, unit cube, matte sphere, orbiting point light, directional sun, lit by the bundled 8k studio HDR.
Default scene Wooden tabletop, unit cube, matte sphere, orbiting point light, directional sun, ambient light. Lit by the bundled 8k studio HDR.
Hearth main menu screen: SwiftUI shell with the engine's title and entry points.
Main menu The SwiftUI shell. The same UI runs on iOS and macOS — the renderer beneath it is the same C++ module.
Animated demo of Hearth's auto-orbit mode on macOS, slowly rotating around the table center.
Auto-orbit (macOS) Press O in the viewport to trigger. Any move or look input cancels it.

Click to select, drag to move, query to iterate.

The editor side has click-to-select with drag-to-move (camera-aligned plane projection), a live transform inspector that updates as you drag, and a settings overlay with backdrop blur over the live frame. Picking uses tight world AABBs so it doesn't snap to invisible boxes around scaled meshes. OBJ, PLY, and USDZ import all work; scenes serialize; reverse-Z depth keeps far-distance precision honest; input is unified across keyboard, mouse, trackpad, touch, and MFi gamepads.

ECS over a scene graph. Entities are 64-bit IDs (32-bit index + 32-bit generation). Components live in SoA arrays keyed on ObjectIdentifier of their type. Systems declare a priority and run in ascending order each frame. Adding a behavior is a small system that queries the components it needs — no class-hierarchy edits.

The pieces underneath.

  • Forward+ clustered shading (16×9×24 grid)
  • Split-sum IBL with GGX-importance-sampled mip chain
  • Hybrid ray-traced shadows on per-mesh BLAS / per-frame TLAS
  • Screen-space reflections, Fresnel-weighted by metallic
  • Volumetric fog & god rays with Henyey-Greenstein phase
  • Bloom + Stephen Hill ACES tonemap
  • Per-material spectral F₀ for physically-tinted metals
  • HDR skybox via fullscreen-triangle env-cubemap sample
  • SwiftUI editor: click-to-select, drag-to-move, live inspector
  • ECS with generational IDs & priority-ordered scheduler
  • OBJ / PLY / USDZ import; scene serialization
  • Unified input: keyboard, mouse, trackpad, touch, MFi gamepad

Swift, SwiftUI, C++, Objective-C++, Metal (via metal-cpp). Xcode 16+, macOS 14+ for the Mac target, iOS 17+ for the iOS target. Apple Silicon recommended for the RT-shadow path.