Real-time 3D engine for iOS and macOS. The renderer is C++ talking to Metal directly through metal-cpp. The editor shell is SwiftUI, and the simulation runs on a hand-rolled ECS with a priority-ordered system scheduler.
The renderer is the part I cared most about. Everything below is wired into the live frame and toggleable from the in-viewport settings overlay.
- 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.
- HDR cubemap environment probe with split-sum IBL. A Radiance
.hdrequirectangular gets parsed on the CPU and 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 vialevel(roughness * maxMip)for roughness-aware specular. A 2D BRDF LUT (Karis split-sum integral) 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. Procedural sky is the fallback when no HDR is bundled. - HDR skybox via a fullscreen-triangle pass at the start of the geometry pass. View direction is reconstructed per pixel from the camera's frustum geometry. Samples the env cubemap with 16× anisotropy.
- Hybrid ray-traced shadows. 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. - Screen-space reflections via per-pixel ray march. Surface normals are derived from depth gradients. The reflection contribution is Fresnel-weighted by metallic (
mix(0.04, 1.0, metallic)), so chrome reflects scene geometry at full strength and dielectrics blend in at ~4%. Roughness is packed into the scene-color alpha for the SSR pass to read. - Volumetric fog and god rays. Fragment-side ray march with a Henyey-Greenstein phase function, exponential height falloff, and IQ-style 3D noise (no banding). In-scattering follows the active directional light. Skybox pixels are explicitly skipped.
- Bloom + tonemap post chain. Bright pass, two-axis blur, composite, then Stephen Hill's fitted ACES (proper HDR-input rolloff instead of Narkowicz's hard clamp at 1.0). Reinhard and Filmic alternatives selectable in settings.
- Per-material spectral F₀.
MeshRenderercarries an RGBf0Tintalongside the usual metallic/roughness/emission. Gold, copper, aluminum, etc. get their actual Fresnel response. Defaultf0Tint=(1,1,1)reproduces the legacymix(0.04, albedo, metallic)behavior.
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. ECS has generational entity IDs and SoA component arrays. OBJ/PLY/USDZ import all work, with tight world AABBs so picking doesn't snap to invisible boxes around scaled meshes. Reverse-Z depth, an event bus, unified keyboard/mouse/trackpad/touch/MFi gamepad input, scene serialization.
A few of the decisions go against the textbook answer.
C++ renderer instead of Swift. The renderer should be the easy part to port if I ever want a Vulkan / Windows build. Keeping it C++ behind a small Obj-C++ shim means Swift hands the bridge integer handles and reads @Published stats back. Swift never sees Metal directly.
Forward+ instead of deferred. On Apple-silicon TBDR hardware, fat G-buffers fight the tile-memory model. Forward keeps MSAA, transparency, and material variation cheap. Forward+ adds one compute prepass and that's it.
ECS instead of a scene graph. Adding a behavior is a small system that queries the components it needs. No class hierarchy edits.
Longer reasoning lives in SPEC.md and the trade-off notes in ROADMAP.md.
Requirements: Xcode 16+, macOS 14+ (Mac target) or iOS 17+ (iOS target). Apple Silicon recommended for RT shadows.
open Hearth.xcodeprojPick Hearth_macOS or Hearth_iOS and run.
The default scene loads a wooden tabletop, a unit cube, a matte sphere, an orbiting point light, a directional sun, and an ambient light. The macOS scheme also spawns a chrome reference sphere at x=4 for A/B-ing PBR + IBL + SSR against a near-mirror surface (omitted on iOS due to an A16 precision artifact on low-roughness reflections). A see-through red/blue procedural grid at y=0 is off by default and toggleable from the viewport toolbar.
The 8k studio HDR ships in Hearth/Resources/HDRI/env.hdr. To swap it for a different Polyhaven capture, replace the file on disk and rebuild. The engine falls back to a procedural sky if the file is missing.
Hearth/
Engine/
Core/ App entry, time, event bus, logging, lifecycle
ECS/ Entity, Component, System, World, scheduler
Components/ Transform, MeshRenderer, Camera, Light, Locked, ...
Systems/ TransformPropagation, FrustumCull, Selection, ...
Input/ Unified keyboard, mouse, touch, gamepad
Resources/ OBJ/PLY/USDZ loaders, mesh generation
Scene/ Scene graph, spatial partitioning, default scene
Rendering/
WarmForge/ C++ renderer (metal-cpp backend)
include/warmforge/ public headers
src/ implementations
bridge/ Obj-C++ shim Swift sees
vendor/metal-cpp/ pinned upstream drop
MetalRenderer.swift Swift ObservableObject around the bridge
Pipeline/MetalView.swift SwiftUI / MTKView host
Materials/Material.swift CPU-side material data
Debug/DebugDraw.swift Forwarder to the C++ debug singleton
Shaders/ .metal shaders + ShaderTypes.h
UI/ SwiftUI views (main menu, files, settings, HUD)
Resources/ Default meshes and textures
- ECS entities are 64-bit IDs (32-bit index + 32-bit generation). Components live in SoA arrays keyed on
ObjectIdentifierof their type. Systems declare apriorityand run in ascending order each frame. - All draw orchestration is C++ in the
warmforge::namespace. Swift ownsMTLDevice,MTLBuffers (insideGPUMesh), andMTLTextures (insideResourceManager). Swift passes integer handles to the renderer; no string lookups on the hot path. WarmForgeRendererBridge.mmis the only file that crosses Swift and C++. It conforms toMTKViewDelegateand forwardsdrawInMTKView:towarmforge::Renderer::drawFrame.Rendering/Shaders/ShaderTypes.his a C header that Swift, C++, and every.metalfile include. It's the single source of truth for layouts:Uniforms,LightData,MaterialData,ClusterLightList,FogUniforms,SSRUniforms.- Reverse-Z depth: near=1.0, far=0.0, comparison
>=, clear=0.0. The depth state is built inwarmforge::GPUStateManager::makeReverseZDepthState(). - Y-up, right-handed. Camera looks down -Z in view space.
- Hearth vs. WarmForge. Hearth is the engine. WarmForge is the internal name of the C++ renderer module. The
warmforge::namespace,Rendering/WarmForge/folder, and#include "warmforge/..."headers all sit behind that module boundary. From Swift, the only surface isRendererBridge(the Obj-C facade); the WarmForge name doesn't leak past the bridge. A future Vulkan/Windows backend would sit beside WarmForge inRendering/and present the sameRendererBridgeAPI.
Rendering/Shaders/myshader.metal,#include "ShaderCommon.h".- Define
vertexandfragmentfunctions (e.g.myshader_vertex,myshader_fragment). - Append a row to
kShadersinRendering/WarmForge/src/Materials/ShaderRegistry.cpp::registerDefaultShaders. - Set
MeshRenderer.materialNameto the new name on whichever entity should render with it.
For depth-only pipelines (shadow casters), pass nullptr as the fragment slot.
struct MyComponent: Component { var value: Float = 0 }
world.addComponent(MyComponent(), to: entity)final class MySystem: System {
let priority = 50
var isEnabled = true
func update(world: World, deltaTime: Float) {
for (entity, comp) in world.query(requiring: MyComponent.self) {
// ...
}
}
}Register it from EngineState.registerSystems() in HearthApp.swift.
- WASD: move
- Q / E: descend / ascend
- Mouse or trackpad drag: look (invert is toggleable)
- Scroll wheel: translate forward/back
- Click a mesh: select. Click again + drag: move on a camera-aligned plane.
- O: toggle a slow auto-orbit around the table center. Any move/look input cancels it.
- Esc: pause menu
- One-finger drag: orbit the camera around the table center in the direction of the drag (drag right → camera sweeps right; drag down → camera rises and looks down).
- Two-finger drag (vertical): zoom. Drag up to zoom in, drag down to zoom out.
- Pinch: also zooms (alternative to two-finger drag)
- Pause button: pause menu
- Left stick: move
- Right stick: look
- Triggers: speed
- Menu button: pause
The PBR shader is Cook-Torrance with metallic-roughness inputs. SSR reads roughness from the scene-color alpha channel, so reflections fade out as roughness goes up. f0Tint is an RGB multiplier on the metallic Fresnel-at-normal-incidence term. Leave it at (1, 1, 1) to keep the legacy behavior, or color it to dial a physically-tinted metal independent of baseColor.
A few useful presets:
| Look | baseColor |
metallic |
roughness |
emission |
f0Tint |
|---|---|---|---|---|---|
| Chrome (mirror) | (0.95, 0.95, 0.95) |
1.0 | 0.05 | 0 | (1.00, 1.00, 1.00) |
| Gold (spectral) | (1.00, 1.00, 1.00) |
1.0 | 0.10 | 0 | (1.00, 0.71, 0.29) |
| Copper (spectral) | (1.00, 1.00, 1.00) |
1.0 | 0.15 | 0 | (0.95, 0.64, 0.54) |
| Aluminum (spectral) | (1.00, 1.00, 1.00) |
1.0 | 0.20 | 0 | (0.91, 0.92, 0.92) |
| Brushed steel | (0.80, 0.80, 0.82) |
1.0 | 0.40 | 0 | (1.00, 1.00, 1.00) |
| Glossy plastic | (0.85, 0.10, 0.10) (any color) |
0.0 | 0.10 | 0 | (1.00, 1.00, 1.00) |
| Matte rubber | (0.05, 0.05, 0.05) |
0.0 | 0.95 | 0 | (1.00, 1.00, 1.00) |
| Glowing emitter | (1.00, 0.80, 0.40) |
0.0 | 0.50 | 2.0 | (1.00, 1.00, 1.00) |
The reference chrome sphere at x = 4 (macOS scheme) is sized and positioned for A/B-ing material parameters against a real near-mirror under the bundled HDR — that's the surface in the hero shot above.
To spawn a primitive shape (cube/sphere/cylinder/cone/torus/plane/capsule/icosphere/pyramid/torus knot), use the + Add menu in the viewport toolbar. The new entity is selected on spawn so you can dial its material in the right-side inspector.
See ROADMAP.md for done / in-progress / planned items across rendering, ECS, tools, platform, and optimization.
PolyForm Noncommercial 1.0.0. Free for personal, hobby, research, and educational use. Commercial use requires a separate license; open an issue to discuss. Full terms in LICENSE.



