Skip to content

DonBeleren/hearth

Repository files navigation

Hearth

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.

Chrome reference sphere reflecting the studio HDR

Default scene under the bundled 8k studio HDR

Main menu

Real-time auto-orbit on macOS — press O in the viewport to trigger

What's in it

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 .hdr equirectangular 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 via level(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₀. MeshRenderer carries an RGB f0Tint alongside the usual metallic/roughness/emission. Gold, copper, aluminum, etc. get their actual Fresnel response. Default f0Tint=(1,1,1) reproduces the legacy mix(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.

Trade-offs

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.

Build and run

Requirements: Xcode 16+, macOS 14+ (Mac target) or iOS 17+ (iOS target). Apple Silicon recommended for RT shadows.

open Hearth.xcodeproj

Pick 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.

Project layout

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

Architecture notes

  • ECS 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.
  • All draw orchestration is C++ in the warmforge:: namespace. Swift owns MTLDevice, MTLBuffers (inside GPUMesh), and MTLTextures (inside ResourceManager). Swift passes integer handles to the renderer; no string lookups on the hot path.
  • WarmForgeRendererBridge.mm is the only file that crosses Swift and C++. It conforms to MTKViewDelegate and forwards drawInMTKView: to warmforge::Renderer::drawFrame.
  • Rendering/Shaders/ShaderTypes.h is a C header that Swift, C++, and every .metal file 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 in warmforge::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 is RendererBridge (the Obj-C facade); the WarmForge name doesn't leak past the bridge. A future Vulkan/Windows backend would sit beside WarmForge in Rendering/ and present the same RendererBridge API.

How to extend

Add a shader

  1. Rendering/Shaders/myshader.metal, #include "ShaderCommon.h".
  2. Define vertex and fragment functions (e.g. myshader_vertex, myshader_fragment).
  3. Append a row to kShaders in Rendering/WarmForge/src/Materials/ShaderRegistry.cpp::registerDefaultShaders.
  4. Set MeshRenderer.materialName to the new name on whichever entity should render with it.

For depth-only pipelines (shadow casters), pass nullptr as the fragment slot.

Add a component

struct MyComponent: Component { var value: Float = 0 }
world.addComponent(MyComponent(), to: entity)

Add a system

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.

Controls

macOS

  • 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

iOS

  • 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

Gamepad (MFi / Xbox / PS)

  • Left stick: move
  • Right stick: look
  • Triggers: speed
  • Menu button: pause

Material cheat sheet

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.

Roadmap

See ROADMAP.md for done / in-progress / planned items across rendering, ECS, tools, platform, and optimization.

License

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.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors