Skip to content

Linux / Vulkan validation on RTX 3090: hwrt:true, 22-scene real-time sweep, and what drives trace cost #72

Open

Description

@onebender137

Built the engine from source on Linux and ran the full bundled demo set through the headless real-time path on a consumer Ampere card. Hardware ray tracing works end to end — "backend":"Vulkan","hwrt":true, fallbacks: 0 on every frame of every scene.

Posting the numbers and the method because the shipped clients are macOS/iOS and I couldn't find any published Linux or NVIDIA figures. No bug report here; separate issues and PRs cover the asset-fetch gaps.

Built at ef00aa448d493f68bbf98d17ccd9783c86375df4 (main, 2026-11-13).

Environment

GPU NVIDIA GeForce RTX 3090, 24 GB (Ampere GA102, deviceID 0x2204)
Driver 580.196.07 (CUDA 13.0)
Vulkan loader 1.3.275; device apiVersion 1.4.312; conformance 1.4.1.3; VK_KHR_ray_query rev 1 present on device
CPU / RAM AMD Ryzen 9 5900X (12C/24T), 128 GB
OS Ubuntu 24.04 (noble), kernel 7.0.0-34-generic
Toolchain rustc/cargo 1.100.0, bun 1.4.4, Node v26.7.0
wgpu 30.0.1 (as vendored)
{"adapter":"NVIDIA GeForce RTX 3090","backend":"Vulkan","device_type":"DiscreteGpu","driver":"NVIDIA","hwrt":true}

Build

git clone https://github.com/meshy-dev/ThreeRT && cd ThreeRT
npm install
bun scripts/setup.ts
node tools/build-scenes.ts
bun renderer/build.ts

Under four minutes from a clean clone: npm install 2s, setup.ts asset fetch (~976 MB) 1m53s, scene bundling 1s, cargo build --release 1m10s.

Method

All real-time figures below come from the headless live path:

threert-render live out/bundles/<scene>.js \
  --assets <dir> --width 1280 --height 720 \
  --scale 1.0 --bounces 4 --fps 240 \
  --frame-log <scene>.jsonl

60 s per scene, shut down with quit on stdin so the frame log flushes cleanly.

Three deliberate choices, each of which changed the result:

live, not play. view.rs passes None to parse_scale, which resolves to auto — the windowed path applies dynamic resolution scaling to hold the fps target. live/session.rs defaults to 1.0. A number measured through play without an explicit --scale is at an unknown internal resolution. We pin --scale 1.0 either way.

Uncapped (--fps 240), not paced at 60. A paced run on this card reports roughly 2.5× the true frame cost:

neon-reflections gpu_mean gpu_max trace_mean interval_mean
--fps 60 (paced) 10.94 ms 15.37 ms 1.65 ms 16.654 ms
--fps 240 (uncapped) 4.37 ms 6.77 ms 1.72 ms 4.384 ms

trace_mean is flat (1.65 → 1.72) — identical work — while gpu_mean more than halves. Two causes, probably both: the 3090 drops power state during the ~6 ms of idle a 60 fps cap hands it, and any other GPU client takes that gap, so the wait lands inside the frame timer. Paced figures are not frame costs.

GPU otherwise idle. Every figure below was taken with nothing else resident on the card.

(One caveat, kept low: main has moved since ef00aa4 — #67–#70 landed, #68/#69 restructure the live passes — but the interval/gpu instrumentation in live/session.rs and the gpu_ms computation are untouched, so these numbers stand on current main.)

Results

Nine scenes where interval_mean ≈ gpu_mean and frame count is consistent with the 60 s window. The other thirteen are excluded for a measurement reason explained below — not because they failed.

scene frames gpu_mean gpu_max trace_mean interval_mean fps history_mean
glb-asset 11497 4.38 5.76 1.95 4.39 227.8 3367
neon-reflections 13432 4.39 6.45 1.72 4.41 226.9 3472
khronos-transmission 8298 4.56 7.34 2.18 4.57 218.7 3085
zero-day 8714 5.85 8.92 3.37 5.86 170.7 3134
bistro 7628 6.59 10.14 3.79 6.60 151.4 2997
bistro-district 4471 6.99 143.63 4.17 7.01 142.7 2220
zero-day-animated 6870 6.65 9.71 3.89 7.31 136.9 32
kabukicho-district-row 4575 11.72 18.86 8.94 11.73 85.3 2263
kabukicho-district 1299 12.28 20.33 9.46 12.28 81.4 650

All times in ms. 1280×720, --scale 1.0, --bounces 4, uncapped.

bistro-district's 143.63 ms max is a first-frame cost, not a frame cost. Re-analysing the log with the first 100 frames dropped puts gpu_max at 8.21 ms, within 1.2 ms of the 6.99 ms mean. It's the initial accel-structure build plus geometry upload on a fresh device.

Static vs scene in motion, same assets

zero-day and zero-day-animated share geometry (MEASURE_ONE.gltf, 5.87 M triangles, 551 animated objects, 412-frame animation), which makes them a controlled pair. history_mean collapsing from 3134 to 32 confirms temporal accumulation resetting continuously in the animated version.

history_mean trace_mean gpu_mean fps
zero-day (converged) 3134 3.37 ms 5.85 ms 170.7
zero-day-animated (in motion) 32 3.89 ms 6.65 ms 136.9
delta +15 % +14 % −20 %

A 14 % GPU cost increase for a fully-resetting temporal history on a 5.87 M triangle scene is a smaller penalty than we expected. Note this is animated scene content rather than a verified moving camera — live is headless, so there is no input, and the motion comes from the bundle's frame script.

Three other scenes show the same ~32 history signature: bistro-slice (31.6), instancing (31.9), mannequin-plaza (31.9).

Why thirteen scenes are excluded

Fully-converged static scenes report an interval_mean below their own gpu_mean, which cannot be right. cornell-box: gpu_mean 2.64 ms, interval_mean 0.36 ms, implying 2759 fps.

Dividing frame count by reported fps gives an implied render duration. Every scene ran for a full 60 s:

scene frames reported fps implied duration
material-grid 14252 3466.6 4.1 s
mannequin 14253 3441.5 4.1 s
cornell-box 14258 2758.9 5.2 s
light-room 14254 1747.0 8.2 s
neon-reflections 13432 226.9 59.2 s ✓
zero-day 8714 170.7 51.1 s ✓
bistro 7628 151.4 50.4 s ✓

The nine scenes in the results table land at 50–59 s, consistent with 60 s minus load. The excluded thirteen land at 3–12 s. Their true throughput by frame count is n/60 — around 237 fps for most, not 1700–3400.

Our reading is that once history converges and nothing in the scene changes, the renderer stops doing per-frame trace work and interval stops tracking wall-clock while gpu retains a stale value. If that's intended, the frame log is easy to misread; if it isn't, the timing instrumentation may be worth a look. Either way we've excluded those scenes rather than publish numbers we can't stand behind. Their trace_mean values are still used in the analysis below, since trace is measured on the GPU timer and is unaffected.

What drives trace cost: emitters, not geometry

The cleanest pair in the dataset, where triangle count runs the wrong direction:

khronos-metal-rough   501,778 triangles,        0 emitters  ->  0.88 ms
glb-asset              15,838 triangles,   15,452 emitters  ->  1.95 ms

32× fewer triangles, 2.2× the trace cost. glb-asset is the DamagedHelmet test asset; 15,452 is the GLB's exact triangle count, verified against the file's index accessor, and its single material is genuinely emissive (emissiveFactor [1,1,1] plus an emissive texture), so the renderer samples the whole mesh as light.

The healthy large scenes scale the same way:

kabukicho-district-row   12.27 M triangles,  112,854 emitters  ->  8.94 ms
zero-day                  5.87 M triangles,   10,103 emitters  ->  3.37 ms

2.1× the triangles, 11× the emitters, 2.65× the cost.

Across all 22 scenes, with emitter counts from a CPU-only stats mode (PR feat/stats-subcommand — the world builds in the embedded runtime before anything touches a device, so every scene has counts):

  • pairwise: trace vs emitters Pearson r = +0.94, Spearman ρ = +0.90; trace vs triangles r = +0.78, ρ = +0.77
  • the predictors are collinear (r = +0.69 — large scenes have more of everything), so neither pairwise correlation separates them
  • controlling for that: partial r(trace, emitters | triangles) = +0.88; partial r(trace, triangles | emitters) = +0.53
  • two-predictor OLS on logs, R² = 0.91 (statsmodels): standardized β = +0.76 for emitters (t = 8.10, p = 1.4e-07) and +0.26 for triangles (t = 2.75, p = 0.013)

Robustness. The set contains two duplicate-geometry pairs — zero-day-animated is byte-identical to zero-day on both predictors (5,869,892 tris / 10,103 emitters), and mannequin-portrait shares geometry with mannequin at zero emitters each — plus one pathological scene (below). Re-fitting three ways:

specification n R² partial r (emi|tri) β_std emitters
as measured 22 0.913 0.881 0.759
duplicate geometry removed 20 0.908 0.877 0.758
also dropping kabukicho 19 0.918 0.890 0.752

Nothing moves. Emitters survive controlling for geometry; triangles survive at about a third the weight. At n = 22 this is suggestive, not conclusive.

The categorical split is the sturdiest part: all 14 scenes with 24+ emitters trace in 1.00 ms or more, and all six zero-emitter scenes in 0.88 ms or less. That includes instancing, whose 768 k triangles are 1,000 copies of an 864-triangle mesh — the BVH holds the base mesh plus transforms, so it is not a unique-geometry-scale test. The two 2-emitter scenes (cornell-box 0.38 ms, light-room 0.44 ms) sit inside the zero-emitter band; two lights don't cost anything yet.

Near-black emissive materials appear to carry full emitter cost

The glb-asset figure is more than an outlier — it points at how emitter cost is attributed. The DamagedHelmet's emissive texture is ~99 % black (mean luminance 0.0033; 98.8 % of texels below 0.05). The helmet emits almost nothing, yet the engine pays full emitter cost on all 15,452 of its triangles. Tracing where the texture enters the chain:

  • CDF proposal — weight per emitter triangle is area × luminance(emissiveFactor) × object power (emitter_power, lib.rs:127) — the factor, not the texture. [1,1,1] is full weight.
  • Live cull — spatial only: EmitterCull { radius, far, dir, cos } (lib.rs:163) — a 60 m radius plus a 2×-radius view cone with a (d0/d)² falloff, d0 = 10 m (live/mod.rs:439-447). No field in the cull or the power path is a radiance threshold.
  • RIS resampling — sample_emitter_ris reweights its six CDF candidates by a geometric target (emitter cosine × the receiving surface's BSDF × 1/d²). The texture does not enter here.
  • NEE — the texture first appears here, and only for the RIS winner: emitter_radiance (pathtrace.wgsl:1672) → emission (:1026) multiplies emissiveFactor × srgb_to_linear(texture) (a coarse 5 cm mip). The six-candidate RIS loop above it is purely geometric — its target is the receiver's BSDF, no emitter texture. In the NEE consumer (:1856-1864) the only gates before the visibility ray are es.pdf_w > 0 (the proposal pdf — power/area/geometry, not radiance) and the receiving BSDF. There is no near-zero-radiance early-out, so occluded_emitter — the shadow ray — fires. A near-black emitter is proposed at full rate, NEE-evaluated at full rate (texture fetch, radiance, and the visibility ray), MIS-weighted, and contributes ~0.

So the wasted work is the full NEE path — the visibility ray included, not just a texture fetch — not merely candidate generation. Beyond the traversal cost, this misallocates importance sampling: samples sent to a black texel are samples not spent on an actual light.

The honest distinction: MATERIAL_FUNCTIONS.md documents this split as intended — "the renderer samples emitters by their base emissive, while the function does the actual emission." That is deliberate for library materials with a glow/intensity parameter, where the factor is the brightness. DamagedHelmet is not a library material — it's a plain imported glTF, where emissiveFactor [1,1,1] is a texture multiplier (the glTF default), not a brightness spec, and the texture carries the actual brightness. So the smaller, fairer question is: is proposal-by-factor intended for glTF imports, where the factor is a multiplier and a mostly-black emissive map is the common case? If not, a material-level effective-luminance term in emitter_power (factor × mean texture luminance) looks like it would be cheap for exactly these scenes.

kabukicho is a separate pathology

It completes 196 frames in 60 s (3.3 fps) with gpu_max 123.23 ms against a 15.41 ms mean — the only scene that never settles into a steady rate. Its 11.12 ms trace sits above everything else in the set, but its district variant has 9.4× the triangles (37.75 M) and 4.8× the emitters (375,688) and traces faster at 9.46 ms, while kabukicho-district-row (12.27 M tris, 112,854 emitters) sits at 8.94 ms. The healthy variants scale sensibly against each other; only this one is out of line, so its timing is untrustworthy and it's kept out of the analysis above.

It reports 2674 objects and 475 ms setup. Happy to share the frame log or dig further.

Our numbers against theirs

The point is portability, not a speed contest: the engine's own 60 fps / 16.7 ms frame budget holds on a second, non-Apple hardware platform — consumer NVIDIA Vulkan with HWRT, full 1280×720, no dynamic resolution — for scenes up to 37.75 M triangles and 375 k emitters. Their published figures are on an Apple M5 (a MacBook Pro laptop SoC, laptop-class power); ours are on a 350 W discrete desktop RTX 3090. The two hardware classes aren't comparable, and most of their live numbers are at a lower internal resolution (0.5 floor) than ours, so the tables below are context, not a benchmark.

incomparability theirs ours
Hardware Apple M5, Metal — laptop SoC NVIDIA RTX 3090, Vulkan, HWRT — 350 W discrete desktop
Resolution mostly the 0.5 floor (640×360) + temporal upscale full 1280×720, --scale 1.0 (4× the pixels)
Metric all-pass GPU ms gpu_mean (all-pass) and trace_mean (trace only)
Pipeline includes #67–#70 ef00aa4 (before #67–#70)
Emitter load Tokyo carries 1.29 M emitter triangles (lit windows) sweep scenes top out at 375 k emitters

Their published real-time figures (M5):

scene tris emitters their figure
Tokyo Shinjuku 17.9 M 1.29 M GPU 10–13 ms @ 0.5 floor, 60 fps
Kabukichō v3 4.0 M 78 k 36 ms @ full 1280×720 → 15.8–16.7 ms @ 60 %, 60 fps
Zero Day 5.87 M 10,103 16.1 ms @ 0.57, 60 fps
Bistro night 2.83 M 20 k 14.75 ms @ 0.5 floor, 60 fps
glb-asset 15,838 — 12.0 ms @ 1280×720, 60 fps

Ours (RTX 3090, full 1280×720, GPU otherwise idle):

scene tris emitters our gpu_mean fps
bistro-district 25.46 M 185,742 6.99 ms 143
kabukicho-district 37.75 M 375,688 12.28 ms 81
zero-day 5.87 M 10,103 5.85 ms 171
glb-asset 15,838 15,452 4.38 ms 228

The only same-scene, like-for-like rows are Zero Day and glb-asset; on both the 3090 runs roughly 2.7× faster than the M5 at equal or higher resolution. That is the expected direction for a 350 W desktop card over a laptop SoC — reported for completeness, not as a result.

Offline renders (different pipeline)

For completeness, the still-image path also works — render ... --spp 32, cinematic preset, atrous denoise. These are whole-image render times in the hundreds of ms and are not comparable to the real-time figures above:

scene triangles emitters total trace denoise
cornell-box 86 2 807.7 ms 19.9 ms 275.2 ms
neon-reflections 61,498 288 914.1 ms 64.2 ms 257.5 ms
sun-room 6,400 96 1,015.3 ms 112.7 ms 244.1 ms

Image quality checks out: cornell-box shows correct GI and colour bleed; neon-reflections' 288 emissive bars render clean, no banding or fireflies, coherent glossy-floor and metal reflections.

Also measured on a second consumer card (RTX 3060, 12 GB)

Same source build, hwrt:true over Vulkan. 14 of 22 scenes fit alongside a ~9.2 GB diffusion workload (renderer peak +0.8–1.6 GB); the 8 heaviest OOM. serve mode: cold job 248 ms GPU init, warm jobs 0 ms — device and game runtimes stay resident between stdin jobs.

render --signals PREFIX writes PREFIX-{diffuse,specular,emission}.raw, each width × height × 3 × 4 bytes (11,059,200 at 1280×720), linear RGB f32 interleaved. Useful as a structural prior for diffusion workloads — light transport instead of a depth map.

Asset fetch on a clean Linux setup

A clean clone leaves 14 of 22 shipped scenes unrunnable, six of them from a single macOS font path that aborts setup.ts before later fetchers run. Filed separately with the full gap table, plus PRs for the two fixable gaps.

Not claimed

  • The ΔE comparisons in docs/COMPARE.md — Mac-only data, not reproduced here.
  • Real-time figures for the thirteen excluded scenes, for the reason above.
  • A verified moving-camera measurement. live is headless; the motion in zero-day-animated comes from its frame script.

Offer

Happy to re-run any of this, add scenes, test other NVIDIA hardware, or share raw data — the sweep is a single script and the frame logs are JSON-lines, so the full per-frame data is easy to hand over if it's more useful than the summaries.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions