Best-effort scene import (pbrt-v4, glTF 2.0, OBJ)¶
Status: glTF 2.0/GLB and Wavefront OBJ/MTL front-ends, October 18, 2026, on top
of the authored-lighting pbrt import of October 13, 2026. Importer ids
pbrt-best-effort-v1, gltf-best-effort-v1 and obj-best-effort-v1; report
schema 1. October 30, 2026: exact vertex compaction retains the supplied PBRT
Bistro's complete triangle geometry within the encoded bundle budget.
Ludus::SceneCook has two pbrt paths. pbrt-reference-v1 is the frozen,
byte-identical cooker for the recorded goldens; it rejects anything it does
not model exactly. The best-effort importers accept general pbrt-v4, glTF 2.0
(JSON or GLB) and Wavefront OBJ scenes, approximate what the runtime cannot
represent, fit the result into the LUSCN01 v7 import profile and explain every
compromise in a versioned JSON report. All of them are native C++; neither the
cook nor its tests run Python.
The three best-effort front-ends only parse: each builds the same
format-neutral intermediate representation (SceneIR,
modules/content/scene_cook/src/importers/scene_ir.h) and hands it to one
shared budget planner, texture cook, bake and report writer. The IR keeps the
complete source data a front-end can express, not the LUSCN capacities, so a
later cooked-format revision can lift the limits without touching the parsers
(see what the import profile cannot carry).
Running it¶
ludus-cook pbrt path/to/scene.pbrt out/scene-dir # writes scene.luscn, import-report.json, LICENSE.txt
ludus-cook gltf path/to/scene.glb out/scene-dir # .gltf or .glb
ludus-cook obj path/to/scene.obj out/scene-dir
ludus-cook import path/to/any-entry out/scene-dir # importer chosen from the file's content
ludus-cook import path/to/any-entry --dry-run # parses and plans; prints the report only
ludus-cook gltf path/to/scene.gltf out/scene-dir --workers 4 # decodes textures on 4 workers + the caller
A dry run reads geometry (so counts and budgets are real) but decodes no
images and packs no bundle. The same entry points are public API in
ludus/content/scene_cook/best_effort_import.h: ImportPbrtBestEffort,
ImportGltfBestEffort, ImportObjBestEffort and the detecting
ImportSceneBestEffort.
Format detection looks at the first 4 KiB, not the extension: the GLB magic
or a leading { selects glTF, an OBJ record keyword (v, vt, f, mtllib,
…) selects OBJ, and a capitalised pbrt directive selects pbrt. Only a
comment-only prefix falls back to the .pbrt, .gltf/.glb or .obj
extension; anything else is reported as unsupported.
The entry file's directory is the import root. Every dependency (pbrt includes, PLY meshes, MTL libraries, external glTF buffers and images) must resolve inside it; parsers receive dependencies through a bounded resolver and never perform file or network I/O themselves. Remote URIs are not fetched.
What is read¶
- A streaming, locale-free tokenizer (64 KiB window, 4096-byte token limit)
with
file:lineon every diagnostic. - Graphics state: every transform directive,
AttributeBegin/End,TransformBegin/End,CoordinateSystem/CoordSysTransform,ReverseOrientation, named materials, textures and media. IncludeandImport, contained in the entry file's directory, limited to a depth of 32 and with cycle detection. A failed include is a warning; a syntax error stops only the file that contains it.ObjectBegin/ObjectInstanceare kept as objects and instances in the IR and expanded when meshes are baked.- Shapes:
plymesh(ASCII, binary little- and big-endian; cached per file),trianglemesh,bilinearmesh,diskandsphere(tessellated) and theloopsubdivcontrol cage. Curves and other shapes are skipped and counted. - Images: see texture and environment decoding.
An unreadable surface texture falls back to a swatch with
TEXTURE_UNREADABLE, while an unreadable lighting environment rejects the import rather than discarding illumination.
PBRT material string normalmap images use linear RGB data, the engine's
MikkTSpace tangent frames and the existing v6 surface-map packing. Image rows
and green values are preserved; the mesh UV conversion supplies the image's
vertical orientation. This retains the authored detail under the engine's tangent
convention, rather than establishing pixel parity with PBRT's shading frames.
Triangle meshes and PLY triangles without stored UVs receive PBRT's per-triangle
(0,0), (1,0), (1,1) defaults before tangent generation; corners split where the
implicit coordinates differ. Other unavailable mappings are reported with
TANGENTS_GENERATED.
Normal image paths must remain inside the import root and join the input digest
closure, including when declared in an included material file.
Shape float alpha and texture alpha use independent material variants, cached
per source material and coverage source. They do not mutate named materials or
other instances. Constants and float constant textures are retained; float
imagemap sources follow PBRT's scalar selection: non-opaque image alpha wins,
otherwise the decoded RGB mean is used. PNG defaults to sRGB decoding, other
containers to linear, with explicit encoding, scale and invert honored.
The declared encoding also decodes alpha, matching PBRT's image-channel access.
The resulting linear coverage is quantized to RGBA8 and tested
at 0.5; fractional/stochastic transparency is approximated by this binary mask
(ALPHA_APPROXIMATED, informational). The base image's alpha does not multiply
PBRT shape coverage. Undefined or unreadable masks fall back to opaque with a
diagnostic. Procedural float textures retain their reported constant fallback.
These conventions follow Matt Pharr, Wenzel Jakob and Greg Humphreys's pbrt-v4 input format, normal-image loading and scalar image selection.
glTF 2.0 / GLB profile (gltf-best-effort-v1)¶
The static triangle-mesh subset of the glTF 2.0 specification. JSON is read through the strict FoundationParsing facade (duplicate keys, invalid UTF-8 and over-limit documents fail); positions in the report are file names only, because JSON values carry no line numbers.
| Feature | Support |
|---|---|
| Containers | .gltf JSON, .glb (JSON + BIN chunks; unknown chunks skipped); asset.version 2.x |
| Buffers | GLB BIN chunk, base64 data: URIs, external files (percent-decoded, inside the import root) |
| Buffer views and accessors | offsets, interleaved byteStride, every component type, normalized, accessors without a buffer view (zeros); bounds checked before any allocation |
| Primitives | TRIANGLES; TRIANGLE_STRIP/TRIANGLE_FAN converted to lists; indexed (uint8/16/32) and non-indexed; points/lines skipped (PRIMITIVE_SKIPPED) |
| Attributes | POSITION, NORMAL, TEXCOORD_0, TANGENT; others (TEXCOORD_1+, COLOR_0, joints, weights) reported (ATTRIBUTE_IGNORED). Without NORMAL every triangle corner gets its own vertex so generated normals are flat, as the specification requires |
| Nodes | hierarchy of matrix or TRS (unit quaternion) transforms, default scene (or all root nodes without scenes); cycles, multiple parents and non-finite transforms fail |
| Instancing | a mesh placed by several nodes stays one IR object with one instance per node; mirrored placements flip winding |
| Materials | baseColorFactor/baseColorTexture, metallic/roughness factors and metallicRoughnessTexture (blue/green, repacked into the surface map), emissiveFactor (+ KHR_materials_emissive_strength), normalTexture, occlusionTexture, emissiveTexture, alphaMode, alphaCutoff, doubleSided; the glTF default material (white, metallic 1, roughness 1) |
| Samplers | repeat or clamp; mirrored repeat and per-axis modes approximated (TEXTURE_APPROXIMATED) |
| Images | PNG and JPEG by URI, data URI or buffer view |
| Cameras | the first perspective camera in scene order (yfov, aspectRatio, znear, zfar); orthographic is approximated |
| Lights | KHR_lights_punctual directional (lux), point and spot (candela) with colour, intensity and cone angles; range reported |
| Extensions | implemented: KHR_lights_punctual, KHR_materials_emissive_strength, KHR_mesh_quantization. Any other required extension fails with EXTENSION_REQUIRED; optional ones are ignored per the specification with EXTENSION_IGNORED (and KHR_texture_transform with TEXTURE_APPROXIMATED) |
| Not imported (reported) | skins and animation (ANIMATION_IGNORED, rest pose), morph targets, sparse accessors, Draco/meshopt without an uncompressed fallback, KHR_texture_basisu/WebP images, KHR_materials_* other than emissive strength |
glTF is right-handed with +Y up and metres, matching the engine's world convention, so nodes are not converted. glTF texture coordinates already have a top-left origin.
Wavefront OBJ/MTL profile (obj-best-effort-v1)¶
Following the Wavefront/Alias OBJ and
MTL descriptions. The entry is
streamed; every diagnostic carries file:line.
| Feature | Support |
|---|---|
| Records | v (extra colour components reported), vt (v flipped to the top-down convention), vn, f, o, g, s, usemtl, mtllib; \ line continuations |
| Indices | positive one-based and negative relative indices for v, vt, vn; zero, malformed or out-of-range indices fail with MALFORMED_INPUT |
| Faces | polygons up to 64 corners fan-triangulated (exact for convex polygons); larger polygons skipped; degenerate triangles removed (GEOMETRY_REPAIRED) |
| Grouping | one IR mesh per (object/group, material) pair (at most 2^18 pairs); equal corner tuples are welded |
| Smoothing | When normals are generated, s off/s 0 faces keep their own corners so they stay flat, and numbered groups share them. Faces with an explicit vn on every corner weld equal v/vt/vn corners whatever the group |
| Missing data | missing vn → area-weighted normals; a group mixing faces with and without vt/vn is made uniform with ATTRIBUTE_IGNORED |
| Not imported (reported) | l/p, free-form curves and surfaces, display attributes |
| MTL statements | newmtl, Kd, Ks, Ke, Ns, d (incl. -halo), Tr, illum, Pr, Pm; Ka, Ni, Tf, sheen/clearcoat keys reported or ignored |
| MTL maps | map_Kd, map_Ks, map_Ns, map_d, map_Ke, map_Pr, map_Pm, bump/map_Bump/map_bump (height), norm (tangent space) with -clamp, -bm, -imfchan, -o/-s/-t (ignored, reported); \ path separators are normalised |
OBJ has no units or handedness declaration; coordinates are read as metres in the engine's right-handed, +Y-up convention.
Phong to metallic-roughness (deterministic):
- When
Pr,Pm,map_Prormap_Pmis present the PBR extension wins: base colourKd, roughnessPr(default 1), metallicPm(default 0). - Otherwise base colour is
Kd(default 0.5 grey), metallic 0, and roughness is(2 / (Ns + 2))^(1/4): Walter et al.'s Phong-to-Beckmann relationalpha = sqrt(2 / (Ns + 2)), with the Beckmann width taken as the GGX alpha (= roughness²).illum 0/1(no specular) gives roughness 1. - A material with no diffuse term (
max(Kd) < 0.04), a strong specular colour (max(Ks) > 0.5), a specular illumination model and nomap_Kdbecomes a conductor with base colourKs(mirrors and polished metals). map_dis a cut-out: the material cooks as a LUSCN v5 alpha mask with a 0.5 cutoff (MTL defines none; this is glTF's default), reported asALPHA_APPROXIMATED(info). The mask comes from-imfchanwhen given, otherwise from the image's alpha if it has any, otherwise from its red (grey) channel;d/Trscale it. A constantd < 1orTr > 0withoutmap_dis blended transparency, which LUSCN cannot store: it cooks opaque (ALPHA_APPROXIMATEDwarning). Refractiveillummodels reportTRANSMISSION_DROPPED.
Texture and environment decoding¶
| Format | Decoder | Notes |
|---|---|---|
| PNG | vendored stb_image | 8-bit; 16-bit samples are reduced by stb |
| JPEG | vendored stb_image | baseline and progressive; EXIF orientation and ICC profiles ignored |
Radiance RGBE (.hdr) |
vendored stb_image | linear float; colour textures clamp and sRGB-encode |
| OpenEXR | private subset reader | single-part scanline or tiled (level 0), HALF/FLOAT/UINT channels, NONE/RLE/ZIPS/ZIP; R/G/B or Y. PIZ, PXR24, B44(A), DWAA/DWAB, deep and multi-part files are Unsupported |
| TGA, PFM | private readers | unchanged |
Formats are detected from magic bytes before the extension. Every decoder checks
the declared raster from the header first and rejects images above 8192 × 8192
pixels (sides up to 16384), so a few bytes cannot declare an enormous decode. The stb
configuration is private (third_party/stb/stb_image_config.h); every stb heap
allocation goes through a bounded per-thread allocator, so an oversized decode
reports out-of-memory instead of exhausting the process. Radiance and OpenEXR
environments keep linear energy and are box-halved (preserving mean radiance)
until they fit 4096 texels and the bundle budget; values beyond the profile's
radiance bound still reject the import as before.
Parallel texture decoding¶
Decoding the texture images (for example inflating Main Sponza's 4K PNGs) is
most of a textured import's time. BestEffortOptions::JobsOrNull (and
AssetOptions::JobsOrNull for the scene asset operations) takes an optional,
caller-owned JobSystem; there is no hidden pool
(threading architecture).
Without one, images are decoded one at a time on the calling thread.
The cook first lists its decodes in the order it consumes them, that is the
base textures and opacity maps slot by slot, then the surface and emission maps.
Each batch of up to min(workers + 1, 8) images is read on the calling thread,
because job callbacks do no I/O. The batch is then decoded in jobs that each
write only their own slot, with the caller helping in Wait. Each job also
hashes the file bytes it decodes; the import's input digests reuse those
digests, so a decoded image file is read once (only images the cook never decodes
are hashed separately). The cook consumes the results in the same order as
before, so diagnostics, input digests and the cooked bytes are identical for
every worker count, zero and none. A decode taken out of that order, for example
after a failed map changes how many maps fit the texture count, is decoded
inline. Cancellation is polled before each image is read. The batch bounds the
extra memory to that many decoded images (64 MiB each at 4096 × 4096). A system
that is busy with another graph makes the import decode serially. One glTF
metallicRoughnessTexture (or an MTL file named by both map_Pr and map_Pm)
is decoded once for both terms.
The same system runs the texture work that follows decoding. Each surface map is
repacked in row bands, 4 × (workers + 1) of them, and each band writes only its
own rows. The budget's halvings (step 8 below) are planned from the texture sizes
alone, in the order of the serial rule, so each texture is then halved its planned
number of times in its own job. These tasks have disjoint outputs and the cook
merges them in texture order, so they keep every worker count byte-identical too.
The scene asset operations then check that no source changed while it was
converted: after the cook, and again before the catalog is published, every
recorded input is hashed again (about 2.2 GB for Main Sponza). With workers in
AssetOptions::JobsOrNull, the calling thread reads the inputs in order into a
buffer of at most 256 MiB and the system hashes the batch, one job per file; a
file larger than the buffer continues in the next batch. The first failure in
input order is reported, so the result and its message (for example "Source
changed during conversion") are the same for every worker count. Without
workers each input is streamed on the calling thread.
ludus-cook ... --workers N (0 to 64; the default 0 decodes serially) and the
Editor, which keeps its own four-worker import system, use this option. Browser
builds decode serially.
Tangents¶
When a material has a tangent-space normal map and a mesh has texture
coordinates but no tangents, MikkTSpace tangents are generated with the
reference implementation by Morten S. Mikkelsen (vendored, unmodified, in
third_party/mikktspace), using the OpenGL texture convention (v up) that
glTF and common bakers assume. Corners whose frames differ get their own
vertices. LUSCN v6 vertices store them (and authored glTF TANGENTs), baked
to world space, orthogonalized against the vertex normal and normalized; a
tangent with no part perpendicular to its normal is stored as absent (all zero),
and the renderer then derives that vertex's frame from its texture coordinates.
The report counts generated tangents (TANGENTS_GENERATED); meshes without
texture coordinates are reported instead.
What the import profile cannot carry yet¶
The planner fits the IR into the LUSCN v7 import profile (256 meshes, materials and textures, 1,024 objects, 256 MiB of textures and a 384 MiB bundle, flattened instancing; see the import profile and surface maps). Materials carry a linear base-color multiplier, a base colour texture with an alpha mask, a linear surface map (tangent-space normal, roughness and metallic) and an emission map; vertices carry tangents. Every source material keeps its slot, so Khronos Sponza (262k triangles, 25 materials) cooks whole with full-resolution base-colour textures, normal maps and per-texel roughness. The IR already holds, and the cook reports dropping:
- height (bump) maps (
NORMALMAP_DROPPED); tangent-space normal maps are stored; - occlusion and specular textures (
TEXTURE_DROPPED); - surface and emission maps beyond the 256-texture count: roughness/metallic
textures are then averaged into the scalar terms (
TEXTURE_APPROXIMATED), and the normal map (NORMALMAP_DROPPED) or emission map (EMISSION_APPROXIMATED) is dropped; - blended transparency (glTF
BLEND, constant MTL dissolve) cooks opaque (ALPHA_APPROXIMATED), and double-sidedness is not stored (SIDEDNESS_IGNORED); - camera aspect ratio and clip planes, and glTF light ranges;
- instancing (objects and instances are expanded into world-space meshes).
How it is approximated¶
| pbrt | Ludus |
|---|---|
diffuse |
typed Lambertian Diffuse with the base colour; raster and render graphs shade it as roughness 1 |
coateddiffuse |
base colour and roughness from the coat. A rough, isotropic, non-scattering coat (GGX alpha at least 0.001, scalar eta of at least 1, zero albedo, default maxdepth 10) keeps the typed CoatedDiffuse layer with its alpha, eta and thickness, which the reference tracers evaluate; nsamples only changes their variance. Other coats, including pbrt's default smooth one, keep the legacy GGX/Lambert terms with MATERIAL_APPROXIMATED; those ignore the layer's internal reflection and shade brighter |
conductor, coatedconductor |
metallic, colour from the named eta/k table |
dielectric, thindielectric |
typed smooth Dielectric/ThinDielectric with scalar eta (default 1.5; a named spectrum keeps 1.5 with SPECTRUM_APPROXIMATED); a rough one imports smooth with ROUGH_TRANSMISSION_APPROXIMATED. The reference tracers render the transmission; raster and render graphs shade the legacy fallback, a smooth reflector with base colour 0.04 |
mix |
the first material |
| other materials | named unsupported models keep their identity as an unresolved material. World shows them in purple and offers a shared material mapping. Unsupported inline materials retain the grey default. Both report MATERIAL_DEFAULTED |
| area lights | emissive material in pbrt-v4 photometric units (RGB keeps its magnitude), source sidedness and optional power; unequal resolved radiances stay separate |
| point, spot, distant lights | transformed world position/direction, RGB candela or lux, optional power/illuminance and spot cone |
| infinite light | authored constant or HDR environment, converted from PBRT equal-area mapping to engine equirectangular mapping |
| unsupported light types | skipped and counted |
| media | skipped |
| glTF / OBJ | Ludus |
|---|---|
| glTF metallic-roughness | base colour factor x texture; metallic/roughness factors stay scalars and multiply the surface map's metallic (blue) and roughness (green) channels |
glTF normalTexture / MTL norm |
surface map RG at the normal map's resolution; scale/-bm is the normal strength, clamped to [0, 8] |
glTF alphaMode |
MASK: LUSCN v5 alpha mask at alphaCutoff, coverage = texture alpha x baseColorFactor alpha; BLEND: opaque (ALPHA_APPROXIMATED) |
glTF / MTL emissive (emissiveFactor x strength, Ke) |
linear RGB nits; decorative emission (see the budget planner); emissiveTexture/map_Ke become an sRGB emission map multiplying it |
| MTL Phong terms | the deterministic conversion in the OBJ profile above |
KHR_lights_punctual |
the analytic light path below: colour x intensity in candela or lux |
Roughness follows pbrt: alpha is sqrt(r) when remaproughness is true and
r otherwise; the engine value is sqrt(alpha), clamped to [0.045, 1].
Spectra are mapped to RGB through a small table, and blackbody emitters use a
cited curve fit. Every approximation adds a diagnostic code.
Authored lighting profile¶
The shared scene profile is LUSCN01 version 5: the version 3 records, with stable placed-object and source identities, emission sidedness, requested lumens, analytic lights and HDR environment pixels; indexed meshes whose per-triangle bake charts are derived rather than stored (version 4); and alpha-masked materials within the import profile. Older v1 to v4 bundles remain readable; v1/v2 keep their historical two-sided emission.
Light units follow pbrt-v4, whose SpectrumToPhotometric normalizes a light's
spectrum to one nit but measures only the illuminant behind an RGB value. An RGB
L or I therefore keeps its magnitude: rgb L [30 30 30] emits 30 nits, times
scale. Blackbody and other spectra, and the default white, are normalized with
fixed Rec.709 luminance 0.2126 R + 0.7152 G + 0.0722 B to one nit, then multiplied
by scale. Requested area-light power resolves per placed shape after scaling as
C × scale × lumens / (π × world area × side count), where C is the RGB value or
the normalized spectrum, so the recorded power is the light's emitted luminous
power. Point power divides by 4π; distant illuminance is lux. Imports made before
this rule divided RGB lights by their own luminance; reimport into a fresh World to
correct their brightness. The engine uses squared spot falloff, so power
normalization integrates that lobe rather than PBRT's smoothstep lobe. Outer
half angle is PBRT coneangle; inner is coneangle - conedeltaangle.
Constant environments follow the same rule, with optional illuminance divided by π. HDR PFM preserves linear energy; PNG/TGA environments decode sRGB. The source light transform rotates the environment into world space. Optional HDR illuminance uses bounded angular quadrature over the source light-space +Z hemisphere. Lighting files enter the source digest closure, so changed sky bytes invalidate the import preview. Values above the profile bounds reject, rather than tone-map the lighting source. One infinite light is supported per PBRT source scene; multiple directives reject explicitly. Separate imported scenes combine through the durable scene-asset assembly contract.
The RGB proxy, material approximations, finite light range and coarse triangle charts prevent pixel parity with PBRT's spectral path tracer. EXR, media, rough transmission and textured area emission remain unsupported. See the renderer lighting contract for viewport area-light, shadow and indirect-transport limits.
Selecting a companion lighting environment¶
A glTF can contain geometry and materials without usable lighting. The full Intel
Sponza main glTF declares 24 punctual lights with zero intensity and does not
reference its bundled textures/kloppenheim_05_4k.hdr. Importing that glTF alone
therefore preserves a dark scene; the importer does not enable those lights. Each
disabled light adds a LIGHT_DISABLED warning to the import report; when no
enabled light or environment remains, the message names the remedies. A disabled
light keeps its authored position and direction. When a World renders almost
black, the editor warns and offers to add a sun aimed like the first disabled
directional light; Add Geometry / Light → Sun adds the same one (see Lighting a World that renders black).
In the World's import preview, choose Choose environment…, then select the bundled HDR inside the scene source folder. Review the updated preview and choose Import previewed scene. Use source lighting removes the selection and rebuilds the preview. Make the same explicit selection when reimporting. When composing several scene imports, select the environment on one of them; their lighting assets add during assembly. The chosen environment is stored in the cooked lighting asset, so reopening the World and assembling it at runtime use that lighting without needing the original file.
Native callers pass the root-relative image name in
BestEffortOptions::EnvironmentFile or AssetOptions::EnvironmentFile; empty
preserves source lighting. This option adds one latitude/longitude environment to
a scene that has none. It rejects a conflicting authored environment, unreadable
image or path that escapes the source directory, including through a symlink.
Analytic lights and material emission remain as authored. The cooked scene's enabled
punctual lights feed the same preview snapshot for raster graphs and the legacy
lighting path; disabled sources remain in the stored definition. It applies to
best-effort entries, not the frozen reference cooker or a custom cooker.
The image uses engine coordinates: +Y is north, longitude zero is +X, increasing longitude turns toward +Z, and the top row borders the north pole. HDR stays linear. Bright peaks are factored into bounded texels and a power-of-two radiance multiplier within the existing lighting representation; their product preserves the retained texels' radiance within binary32 precision. No exposure or clipping is baked into this choice. The image is hashed during preview and import, and the choice itself participates in the approval token. Changed bytes invalidate pending publication. The report records the image, dimensions and retained radiance multiplier.
The ordinary raster viewport uses cosine-convolved diffuse SH and roughness-filtered specular environment lighting. It remains interactive without reference path tracing. This profile has no environment visibility, local occlusion or indirect bounce transport; interior lighting can leak through walls. It does not reproduce the scene's offline reference renders. CPU and GPU reference paths use the same retained lighting with their existing transport and visibility rules. Environment storage shares the declared import byte budget with geometry and material maps.
Budget planner¶
The cooked scene must fit the LUSCN v7 import profile: 256 meshes, materials and textures, 256 MiB of textures and a 384 MiB bundle. The former independent 2M-vertex and 6M-index ceilings are removed: geometry is admitted by its actual encoded bytes, including 48 bytes per v6 vertex and four per index. Every placement is retained, or the import fails explicitly before baking when its complete geometry cannot fit. Texture and lighting storage share the remaining bundle allowance; renderer CPU/GPU budgets still apply independently.
Before planning, the shared cook compacts byte-identical position/normal/UV/tangent records within each mesh, after tangent generation. First-occurrence vertex order, every triangle, winding and attribute seams are preserved; signed zeros remain distinct. Meshes without normals keep their original topology so later generated smoothing does not change. The temporary index/remap arrays use eight bytes per source vertex, with one mesh processed at a time; this is separate from encoded file, cooked bundle and runtime budgets.
The schema-1 report adds source.indices and cooked.indices (three indices per
triangle). source.vertices counts authored records before tangent splitting and
compaction, including expanded placements. Reduced cooked vertex counts accompanied by
VERTICES_COMPACTED are exact sharing. Geometry admission retains every placement
or rejects the import; the legacy dropped-placement counters remain zero. Source
vertex/index counts saturate at the uint32 maximum. The frozen reference cooker is unchanged.
Each cooked mesh carries one material, so the planner:
- reserves one slot per distinct emitter radiance, sidedness and power-normalization group; groups record their source shapes and area-light directives in the report. Decorative groups keep their source material's textures and maps. Required PBRT emitters retain the white base-colour approximation, split per source material when normal detail or shape coverage differs, and retain those normal maps and masks;
- gives every other source material its own slot while they fit; beyond 256 slots it ranks ordinary placements by world-space area and keeps the largest material groups;
- merges each remaining material into the nearest kept slot
(
MATERIALS_MERGED); a merged material takes its slot's alpha mask; - merges all geometry of a slot into one world-space mesh;
- keeps each placement's vertices shared (consumers derive the guttered
per-triangle bake charts from the indices), and checks the complete geometry
against the bundle byte budget. An over-budget scene reports
LIMIT_EXCEEDEDand produces no bundle; ordinary and emitter placements are never dropped; - writes each masked slot's coverage into its cooked base texture's alpha:
the texture's own alpha (glTF), or the MTL/PBRT opacity map sampled nearest at the
base resolution, times the constant opacity. An untextured masked slot takes
the opacity map's resolution. A mask that keeps every texel cooks opaque, and an
unreadable opacity map leaves the slot opaque (
TEXTURE_UNREADABLE); - repacks each slot's tangent-space normal map (RG), roughness channel (B) and metallic channel (A) into one linear surface map at the normal map's resolution (otherwise the larger data texture's), sampling the others nearest; an absent normal is flat (0.5) and an absent multiplier one. Emissive slots cook their emission texture as an sRGB emission map. Byte-identical base swatches are shared after mask application; surface maps with identical source/channel selections are shared with independent per-material strengths and scalar factors. These maps follow the base textures while the bundle's 256 textures allow; beyond that a slot's roughness/metallic textures are averaged into its scalars;
- halves the largest texture, oversized ones first, until every texture fits
4096 texels and 16 MiB and all fit the byte budget (
TEXTURE_DOWNSCALED). Geometry and authored lighting bytes are reserved first. RGB color and emission are decoded to linear light before box filtering and encoded back to sRGB. Packed normal RG reconstructs positive Z, averages unit directions and renormalizes; roughness, metallic and coverage remain linear scalar channels. Untextured slots get a 4×4 sRGB swatch. Halving averages coverage too, which slightly erodes thin cut-outs.
The v7 material extension retains the linear base-color factor separately when the base image decodes. Shading multiplies it after sRGB decoding, including after asset splitting and World assembly. A constant or fallback swatch already contains its factor, so its retained multiplier is white. Best-effort extensions record the representative source index but currently have no counted source name. Re-import existing scenes to apply the corrected texture reductions and retained v7 material factors to their cooked assets.
The editor's material graph uses bilinear reconstruction of the retained mip
and carries alpha-mask thresholds into raster, closest-hit and shadow coverage.
It does not retain per-map sampler differences or mip chains. This does not remove
texture admission budgets: Intel Sponza's 72 source 4K PNGs decode to 4.5 GiB of
RGBA8 before geometry, so the import still reduces resolution to fit its declared
budget. Its single BLEND dirt-decal material still receives the explicit opaque
approximation diagnostic; correct translucent composition and material reflection
sidedness remain separate viewport limitations. Source vertex colors in the
supplied main glTF are uniformly white; general nonwhite vertex-color import
remains unsupported.
A scene rejects when distinct emitter groups cannot fit the 256 mesh slots, required emitter geometry exceeds capacity, lighting exceeds 128 analytic lights, or authored energy cannot fit the finite profile. It does not merge all emitters into the brightest light or clamp their energy to make the import succeed. Emitters with equal radiance, sidedness, power and material share one group, which is also the unit the World's Lights list switches.
pbrt area lights are required emitters. glTF emissive materials and MTL Ke
are decorative: their units are not photometric, so when emitter slots run
out (keeping one slot for ordinary surfaces) the smallest decorative emitter
groups, or decorative emission beyond the radiance profile, are cooked as
ordinary surfaces with EMISSION_DROPPED instead of failing the import.
The output is deterministic: the same input yields the same bundle and report bytes. Paths in the report are relative to the entry directory.
Importing in the Editor¶
World Import scene… (and the Level surface's Import) accepts .pbrt,
.gltf, .glb and .obj files, and Ludus's own
scene source files (.luscene.json),
including the engine's test scenes (the browser editor also accepts a scene folder
or .zip containing one). glTF and OBJ entries are routed to their best-effort
importers by content. For pbrt, Ludus::SceneCook picks the importer from the
file name:
scene-v4.pbrtfirst usespbrt-reference-materials-v2, preserving staircase2's source material identities and optical parameters. Only when that bounded profile rejects the scene does import fall back topbrt-best-effort-v1; the preview says why.AssetOptions::ShouldPreserveReferenceMaterials = falseexplicitly selects the frozenpbrt-reference-v1compatibility profile.- Any other
.pbrtfile usespbrt-best-effort-v1. - A source directory (the older flow and its tests) stays on the reference profile with no fallback.
Choosing a file first runs a dry-run preview on the scene worker thread, with progress and Cancel scene operation. The preview panel lists source and cooked counts, budget use against the runtime limits and every warning-level fallback. Nothing is written until Import previewed scene; Discard preview drops it.
A best-effort import stores its full report in the scene manifest, beside the
input digests that guard against a source changing mid-import. Opening or
reassembling a World returns these reports as import_reports, and the
Import report toggle beside the World path opens them in the scrollable
inspector. Reports start collapsed so their length does not squeeze the viewport
or hierarchy controls.
Reference imports have no such report; their approximations still appear as
warnings.
Tests¶
ludus_best_effort_import_tests writes a synthetic fixture from C++ that
covers nested, cyclic and escaping includes, every shape and image format,
fallback materials, lights, media and instancing. It checks diagnostics,
runtime decoding, determinism, merging, budget drops and dry runs.
ludus_scene_import_tests covers the glTF, OBJ, image and tangent paths with
checked-in CC0 fixtures under modules/content/scene_cook/tests/fixtures:
an OBJ/MTL room (quads, negative indices, smoothing groups, Phong and PBR
materials, an emitter, a normal map), an instanced glTF scene with an external
buffer and the same scene as a self-contained GLB (whose cooked bundles must be
byte-identical), a data-URI JPEG asset, and image fixtures written by
independent encoders (Pillow JPEG, the OpenEXR library, Ward RGBE). The
generators generate_scene_fixtures.py and generate_image_fixtures.py beside
them document provenance; the build does not run them. Malformed, truncated,
out-of-range, unsupported, escaping-path, cancellation and budget cases are
generated in memory. ludus_scene_assets_tests imports the glTF, GLB and OBJ
fixtures through the editor's preview, import, commit and assemble path.
texture_decode_tests.cpp (part of ludus_scene_import_tests) treats a cook
without a job system as the oracle. It requires byte-identical bundles, reports
and input digests from systems with 0, 1, 3 and 7 workers. The scenes cover every
map kind, unreadable and undecodable images, failures at the texture-count limit,
odd-sized textures halved once or more, and surface maps sampled across
resolutions; the serial halved and repacked textures are also compared with
references built texel by texel. It also tests a shared metallicRoughness image,
out-of-order takes, a busy system and cancellation.
Configure with -DLUDUS_BUILD_SCENE_COOK_FUZZERS=ON (native Clang) to build
ludus_scene_cook_pbrt_fuzzer, a libFuzzer target over the pbrt reader, PLY
reader and image decoders, and ludus_scene_cook_import_fuzzer over the glTF
and OBJ readers, the OpenEXR/Radiance/JPEG decoders and tangent generation.
Seed it with the fixture directory.
Local smoke test with licensed scenes¶
Licensed scenes are never committed. To import them locally:
LUDUS_PBRT_SCENES_DIR=~/Ludus/references/resources/scenes \
ctest --test-dir out/build/linux-clang-development -R best_effort --output-on-failure
LUDUS_PBRT_SCENES_DIR=... LUDUS_PBRT_SMOKE_OUTPUT=/tmp/staircase \
out/build/.../ludus_best_effort_import_tests "[licensed]"
The test skips when staircase/scene-v4.pbrt is missing under the directory.
For glTF and OBJ collections (for example the Khronos glTF sample assets or the
McGuire Computer Graphics Archive), set LUDUS_GLTF_SCENES_DIR and/or
LUDUS_OBJ_SCENES_DIR; every .gltf, .glb and .obj below them must import
or fail with a report that explains why:
LUDUS_GLTF_SCENES_DIR=~/glTF-Sample-Assets/Models LUDUS_OBJ_SCENES_DIR=~/mcguire \
out/build/linux-clang-development/modules/content/scene_cook/ludus_scene_import_tests "[licensed]"
PBRT Bistro geometry acceptance¶
The supplied bistro/bistro_cafe.pbrt uses 865 PLY meshes and 741 inline triangle
meshes, with 1,606 placements and 2,838,960 triangles. Thanks to Amazon Lumberyard
for the Bistro assets; retain the supplied LICENSE.txt. Assets and generated
reports stay outside source control. With that licensed input available:
ludus-cook pbrt references/resources/scenes/bistro/bistro_cafe.pbrt out/bistro --workers 4
LUDUS_PBRT_SCENES_DIR=references/resources/scenes \
out/build/macos-clang-development/modules/content/scene_cook/ludus_best_effort_import_tests '[bistro]'
LUDUS_EDITOR_IMPORT_ENTRY=references/resources/scenes/bistro/bistro_cafe.pbrt \
out/build/macos-clang-development/modules/content/scene_cook/ludus_scene_assets_tests '[scene_assets][licensed]' -s
Use the equivalent native build directory on Linux. The optional test skips when the input is absent; when present it checks the supplied version's source counts, zero geometry drops, preservation of all indices and decoding through the runtime Resources consumer. Ordinary generated regressions independently compare every triangle corner's attribute bytes before/after compaction, including normal/UV/ tangent seams, signed zeros, missing normals, malformed input and cancellation. The catalog test exercises Editor import, commit and assembly, then checks the same complete geometry through Resources decoding.
The unshared input represents 442,773,216 bytes of v6 vertex/index records before headers or textures, already above 384 MiB. Exact sharing makes the complete geometry fit the byte-based admission policy without introducing another fixed vertex or triangle ceiling.
Measured on October 30, 2026 with native macOS arm64 Clang 23.1.3, Development, warnings as errors, and four texture workers:
| Quantity | Previous 2M-vertex / 6M-index profile | Compaction + complete-geometry byte admission |
|---|---|---|
| Source vertex records | 8,514,702 | 8,514,702 |
| Retained triangles | 667,392 | 2,838,960 |
| Cooked vertices | 1,999,998 | 2,892,578 |
| Dropped placements | 1,432 | 0 |
| Bundle bytes | 368,253,580 | 402,583,744 |
| Textures reported downscaled | 3 | 99 |
The current report has source.indices == cooked.indices == 8,516,880 and no
shape failures or geometry drops. Bundle and texture byte ceilings remain
384 MiB and 256 MiB; retained geometry takes priority over texture resolution.
The initial Editor-path run rejected its dependency closure even though this
bundle fit: material JSON, the scene manifest and license add encoded bytes that
are absent from the bundle. MAX_RESIDENT_CLOSURE_BYTES now owns a separate
512 MiB logical-closure ceiling, while single artifacts and assembled bundles
retain their existing ceilings. Logical aliases are charged independently even
when they share a physical artifact file. This admission limit does not describe
peak import memory or decoded renderer residency.
With PBRT normal maps and shape coverage enabled, the same scene keeps all
1,606 placements and 8,516,880 indices, with 2,922,810 cooked vertices (MikkTSpace
frames keep additional seams), 136 material/mesh slots and 252 texture images.
All 131 used normal images survive, serving 135 material slots, and 17 material
slots retain cutout masks, including emissive surfaces. No normal maps are dropped
for texture capacity, and no texture reads fail. The bundle is 401,941,792 bytes;
210 images are downscaled to preserve geometry under the existing byte limits.
Eight light meshes omit UVs; retaining PBRT's implicit per-triangle coordinates
lets them carry normal detail as well. The six dielectric transmission drops remain.
The report now includes each slot's normal_texture, alpha_masked and
alpha_cutoff; material declaration counts and labels exclude the synthetic
shape-mask variants. Required PBRT emitters retain white base reflectance with
their authored normal detail and shape masks. Full geometry import/runtime decoding does not qualify
interactive viewport performance, GPU memory use or PBRT material/lighting parity.
Earlier SDKs can reject the larger geometry counts even though the LUSCN v6 wire
layout is unchanged.
Third-party code¶
| Dependency | Use | Pin and licence |
|---|---|---|
| stb_image 2.30 (Sean Barrett) | PNG, JPEG, Radiance decoding and the zlib inflater used by the EXR reader | third_party/stb, SHA-256 checked, MIT/public domain |
| MikkTSpace (Morten S. Mikkelsen) | tangent generation | third_party/mikktspace, SHA-256 checked, zlib |
Both are C, exception-free and private to Ludus::SceneCook. OpenEXR is read by
an original subset reader instead of the OpenEXR library, which needs C++
exceptions and Imath and supports far more than environment maps require;
the subset's unsupported compressions report Unsupported explicitly.