Skip to content

Fabricate Renderer

StandardRenderer is the quick path into the fabricated renderer: a scene you have only registered geometry into comes back lit, shadowed and ambient-occluded by the stock setup. Metalness changes the material, shadows land where objects touch the ground, and ambient occlusion darkens contacts. Tone mapping is the one thing you have to ask for (see Tone mapping and bloom).

This page shows the renderer in use. For experimental area lights, Vulkan ray-query shadows and optional RTXDI resampling, see Direct lighting. The Renderers reference page lists every public declaration in the fabricated package with its KDoc, including the scene graph and pass classes the examples below use, and the two renderers this page does not cover: the progressive path tracer and the Gaussian splat renderer.

The most demanding scene on the page is assets/fabricate_demo/ToyCar.glb, paired with the venice_sunset_1k.exr environment. ToyCar carries transmission, clearcoat, sheen, tangent-space normal maps, texture transforms, and multiple texture semantics. Venice Sunset drives the image-based-lighting path through LoadedTexture.fromExrFile(...), which decodes EXR channels into RGBA32F, can build GGX-prefiltered radiance mips, and samples that mip chain by roughness.

How the captures on this page are made

The docs runner drives StandardRendererDocsHarness: it builds FabricateNode and FabricateMaterial state, binds renderer meshes to node world transforms, asks FabricateScenePlanner for the draw and material queues, then attaches the FabricateFrameGraph for capture.

The common pattern is:

val bundle = StandardRenderer.build(renderer)

val material = FabricateMaterial(
    baseColor = Vector4f(0.83f, 0.58f, 0.28f, 1f),
    metallic = 0.18f,
    roughness = 0.36f,
)

val node = bundle.sceneGraph.mesh(name = "torus", material = material) {
    transform.set(Matrix4f().translate(0f, -0.18f, 0f))
    renderable?.userData = "torus"
}

val mesh = bundle.registerTestTorus(name = "torus", slices = 64, stacks = 64, renderer = renderer)
mesh.bindNode(node)
mesh.applyFabricateMaterial(material)

val plan = bundle.applyScenePlan()
println(plan.opaque.map { it.node.name })
println(plan.materials.first())

bundle.attachTo(renderer)
bundle.present(renderer = renderer)

StandardRenderer.Bundle is the object to keep around. It owns the off-screen render targets, the modules that draw into them, the scene defaults, the scene graph, and the post stack. Most sketches should build one bundle, register geometry into it, attach its frame graph to a window, then present the post-stack output.

Use the high-level layer for:

  • bundle.sceneGraph: hierarchy, transforms, animation samples, visibility, material intent, and glTF import.
  • bundle.registerCube(...), registerSphere(...), registerPullingMesh(...): geometry that should render through both the g-buffer and shadow pass.
  • bundle.registerTransparentPullingMesh(...): blended geometry that should draw after deferred lighting.
  • bundle.addLight(...) + bundle.syncLights(): directional/point/spot lights; bundle.showEnvironmentBackground() draws the environment map as the backdrop.
  • bundle.postStack, bundle.addShaderEffect(...), bundle.setToneMapping(...), bundle.addBloom(...): fullscreen image effects, tone mapping, and bloom after opaque and transparent composition.

Drop down to raw Module, Mesh, RenderToTexture, or push constants when you need a shader or buffer layout that the bundle does not know about yet. Keep transform/material intent in the scene graph even then, and let the planner classify it before the module maps are committed.

The current built-in frame graph is:

std - gbuffer
std - shadow
std - ao
std - lighting
std - transparency
std - motion - blur
std - aa

Inspect or edit that order before attaching it:

val graph = bundle.frameGraph()
println(graph.passNames())

// Insert custom utility passes around the named built-ins.
graph.insertBefore("my-pre-light-pass", FabricateFrameGraph.PASS_LIGHTING, myTarget)
graph.attachTo(renderer)

bundle.attachTo(renderer) is the convenience form for bundle.frameGraph().attachTo(renderer). bundle.present(...) presents bundle.postStack.output(), so any effect you append after the stock stack becomes the final image automatically.

Editing the built-in shaders

Fabricate's own passes are ordinary Modules carrying GLSL source, so they can be bound to boxes and edited/hot-reloaded exactly like a shader you write yourself. bundle.bindShadersToBoxes() creates one box per pass — g-buffer, shadow, AO, lighting, transparency, motion blur, FXAA, plus any post effects (tone map, bloom, your own) and textured g-buffer variants from glTF import. Each box shows the pass's vertex/fragment GLSL in editable tabs; alt r reloads it, alt shift enter does an ABI-preserving live pipeline swap.

val bundle = StandardRenderer.build(renderer)
bundle.setToneMapping(FabricateToneMap.ACES)
bundle.addBloom()

// Bind every built-in pass (and the post effects above) to editable boxes.
val boxes = bundle.bindShadersToBoxes()
println(
    boxes.keys,
) // std-gbuffer, std-shadow, std-ao, std-lighting, std-transparency, std-motion-blur, std-aa, std-post/bloom-composite,

// std-post/tonemap, ...

// Or hack on a single pass:
bundle.bindShaderToBox("std-lighting", bundle.lightingModule)

Reloads reuse the pass's existing Parameters, so the renderer's texture bindings, attachment formats, and push-constant seeding are preserved — only the GLSL changes. Edit the lighting fragment to try a different BRDF, or the FXAA pass to swap in a different AA, and the change is live on the next frame. bundle.shaderModules() returns the same name → ModulePointer map for programmatic access, and bundle.shaderSources() returns the raw vertex/fragment text for inspection without a session.

Scene graph and transforms

Fabricate has a small scene layer on top of Field 4: nodes, local transforms, world transforms, render intent, animation samples, and draw-list ordering. The scene graph is pure Kotlin; renderer modules consume it through bound model matrices.

val bundle = StandardRenderer.build(renderer)
bundle.attachTo(renderer)

val parent = bundle.sceneGraph.node("pivot") {
    transform.position.set(0f, -0.2f, 0f)
}

val glass = bundle.sceneGraph.mesh(
    name = "glass",
    parent = parent,
    material = FabricateMaterial(Vector4f(0.45f, 0.70f, 1.0f, 0.35f)).transparent(0.35f),
) {
        transform.position.set(0.4f, 0f, -0.45f)
        transform.scale.set(0.7f, 0.7f, 0.7f)
    }

bundle.sceneGraph.updateWorldTransforms()
val worldMatrix = Matrix4f(glass.worldMatrix)

Draw lists are computed from the same tree. Opaque items are sorted front-to-back, transparent items back-to-front, and shadow casters are separated:

val lists = bundle.sceneDrawLists()
println(lists.opaque.map { it.node.name })
println(lists.transparent.map { it.node.name })
println(lists.shadowCasters.map { it.node.name })

Once the bundle is attached, it re-sorts transparent meshes for the camera every frame, so moving transparent objects stay in order without a call.

Render planning

The scene graph answers "what exists?" and the render plan answers "what order should it draw in?" FabricateScenePlanner walks the current scene, resolves material records into a compact table, and splits draw items into opaque, alpha-mask, transparent, and shadow-caster queues.

val planner = FabricateScenePlanner()
val plan = planner.plan(bundle.sceneGraph, cameraPosition = Vector3f(bundle.scene.camera.at))

println(plan.opaque.map { it.node.name })
println(plan.alphaMask.map { it.node.name })
println(plan.transparent.map { it.node.name }) // back-to-front
println(plan.materials.size)

The planner is pure Kotlin. Because it allocates no Vulkan objects, you can run it in tests and harnesses. A FabricateRenderItem carries the node, renderable, material id, queue, current world matrix, previous world matrix, and camera distance. A FabricateMaterialRecord is the CPU-side handoff shape for material buffers: base color, metalness, roughness, alpha mode, cutoff, double-sided state, and a texture mask placeholder.

Style rule: keep user intent on FabricateMaterial and FabricateRenderable; let the planner classify it. Prefer FabricateAlphaMode.OPAQUE, .MASK, and .BLEND over ad hoc booleans, and use material.sortOrder only when a stable override matters.

Default scene

The default scene is a plain one: three colored torii, the standard camera, and the stock lighting setup. It is what StandardRenderer.build(...) gives you before you have chosen anything, so it is also the shortest route to a picture.

The scene is node/material creation plus mesh binding, and nothing else:

fun torusNode(name: String, x: Float, color: Vector4f) {
    val material = FabricateMaterial(baseColor = color)
    val node = bundle.sceneGraph.mesh(name = name, material = material) {
        transform.set(Matrix4f().translate(x, 0f, 0f))
        renderable?.userData = name
    }
    val mesh = bundle.registerTestTorus(name = name, slices = 48, stacks = 48, renderer = renderer)
    mesh.bindNode(node)
    mesh.applyFabricateMaterial(material)
}

torusNode("left", -0.95f, Vector4f(0.17f, 0.98f, 0.24f, 1f))
torusNode("middle", 0f, Vector4f(0.98f, 0.92f, 0.22f, 1f))
torusNode("right", 0.95f, Vector4f(0.98f, 0.30f, 0.32f, 1f))

three torii seen face-on against black, lit from above so each carries a soft sheen along its upper edge: green on the left, yellow in the middle, dusty red on the right. All three lie in one plane and overlap, and the renderer resolves the interpenetration rather than blending it, so the middle ring is cut into curved patches of green and red wherever the outer two pass through it

Shiny torii on checkerboard planes

Reflective torii over a checkerboard floor: specular highlights, contact shadows where an object meets the ground, and shading that stays stable as the geometry gets denser.

The checkerboard here is mesh-side: alternating tile geometry receives alternating FabricateMaterial values. The harness packs the two parities into pulling meshes for fewer draw items; conceptually, the scene logic is this:

bundle.scene.camera.at.set(0f, 0.78f, -3.75f)
bundle.scene.camera.target.set(0f, -0.25f, 0f)
bundle.scene.lightDirection.set(Vector3f(-0.55f, -1.0f, -0.20f).normalize())
bundle.scene.lightIntensity = 1.55f
bundle.scene.shadowStrength = 1f
bundle.scene.aoStrength = 1f

for (row in 0 until 10) {
    for (col in 0 until 10) {
        val light = (row + col) % 2 == 0
        val material = FabricateMaterial(
            baseColor = if (light) Vector4f(0.57f, 0.57f, 0.61f, 1f) else Vector4f(0.22f, 0.22f, 0.24f, 1f),
            roughness = 0.9f,
        )
        val node = bundle.sceneGraph.mesh(name = "tile-$row-$col", material = material) {
            transform.set(
                Matrix4f().translate((col - 4.5f) * 0.52f, -1.0f, (row - 4.5f) * 0.52f).scale(0.26f, 0.02f, 0.26f),
            )
            renderable?.userData = "tile-$row-$col"
        }
        val tile = bundle.registerCube(name = "tile-$row-$col", size = 1f, renderer = renderer)
        tile.bindNode(node)
        tile.applyFabricateMaterial(material)
    }
}

val torusMaterial = FabricateMaterial(Vector4f(0.28f, 0.86f, 0.96f, 1f), metallic = 1.0f, roughness = 0.07f)
val torusNode = bundle.sceneGraph.mesh(name = "torus", material = torusMaterial) {
    transform.set(Matrix4f().translate(0f, -0.16f, 0f).rotateX(0.24f).rotateY(-0.25f))
    renderable?.userData = "torus"
}
val torus = bundle.registerTestTorus(name = "torus", slices = 72, stacks = 72, renderer = renderer)
torus.bindNode(torusNode)
torus.applyFabricateMaterial(torusMaterial)

a floor of separate square tiles receding to a horizon, each tile catching its own small specular highlight, with black above. Three glossy torii stand on it and overlap left to right: a dull red one furthest back, a teal one in the middle, and an olive-yellow one nearest and largest. Each meets the tiles in a dark contact shadow

Two camera framings over one scene

The same checkerboard setup, geometry unchanged, seen from two cameras: a near framing and a far one, so the depth layering reads off one image.

Nothing changes between the two halves except camera.at and camera.target:

bundle.scene.camera.at.set(-1.55f, 0.72f, -1.75f)
bundle.scene.camera.target.set(-0.95f, -0.2f, 0.95f)

// capture the near framing

bundle.scene.camera.at.set(1.45f, 0.78f, -3.35f)
bundle.scene.camera.target.set(0.95f, -0.15f, -1.35f)

// capture the far framing

two framings of one checkerboard-and-torii scene, side by side. In the near framing the floor fills the lower half, a brown torus stands behind a teal one, and the olive torus is so close to the camera that only a crescent of it fits against the edge of the panel. In the far framing the same three rings sit smaller and higher with more floor visible, the olive one in front, the teal behind it, and the brown showing through both openings

Shader-generated checkerboard

This variant keeps floor geometry to a single scaled cube and generates the checker tiles procedurally in the g-buffer fragment shader. Use shader-generated tiles when you want lower mesh/buffer overhead; use mesh-split tiles when you need per-tile transforms or per-tile material control. The checker pattern is evaluated in world space, so model scaling directly affects tile density.

Limitations:

  • No per-tile transforms or per-tile material edits (all tiles share one mesh/material state)
  • World-space scale dependency for checker density
  • Shader-only pattern can alias at grazing angles compared with textured/filtered approaches

The mesh is one scaled cube. Fabricate owns the transform/material intent; the checker controls are extra shader parameters on the g-buffer mesh:

val material = FabricateMaterial(
    baseColor = Vector4f(0.58f, 0.58f, 0.62f, 1f),
    roughness = 0.9f,
)
val node = bundle.sceneGraph.mesh(name = "shader-floor", material = material) {
    transform.set(
        Matrix4f().translate(0f, -1.0f, 0f).scale(3.2f, 0.02f, 3.2f),
    )
    renderable?.userData = "shader-floor"
}
val floor = bundle.registerCube(name = "shader-floor", size = 1f, renderer = renderer)
floor.bindNode(node)
floor.applyFabricateMaterial(material)

floor.gBuffer.mesh.push["checkerScale"] = 3.0f
floor.gBuffer.mesh.push["checkerColor"] = Vector4f(0.20f, 0.20f, 0.22f, 1f)
floor.gBuffer.mesh.push["checkerMix"] = 1f

bundle.applyScenePlan()

a brown torus and a larger teal one against black. The floor is a single scaled cube rather than a grid of tiles, and the checker is a fine procedural pattern on its surface: a narrow band of it shows through the teal ring and tapers away to a point beyond the ring, with the tiles far smaller and denser than the mesh-split floor renders them

Blended transparency

Transparent meshes render after the deferred lighting pass. Fabricate copies the lit opaque scene into transparentComposite, then draws transparent pulling meshes with alpha blending while sampling opaque g-buffer depth to discard hidden fragments.

The scene graph supplies the ordering; the transparency pass supplies the blending:

val node = bundle.sceneGraph.mesh(
    name = "near-glass",
    material = FabricateMaterial(Vector4f(1.0f, 0.78f, 0.38f, 0.38f)).transparent(0.38f),
) {
        transform.set(Matrix4f().translate(0.42f, -0.28f, -0.38f).scale(0.66f))
        renderable?.userData = "near-glass"
    }

val glass = bundle.registerTransparentPullingMesh(
    name = "near-glass",
    maxVertices = 32 * 32 * 6,
    renderer = renderer,
)

StandardShapes.sphere(
        positions = glass.position,
        normals = glass.normal,
        radius = 1f,
        slices = 32,
        stacks = 32,
        config = StandardShapes.WriteConfig(append = false),
    )
    .also { glass.setVertexCount(it.writtenVertices) }

glass.bindModel { Matrix4f(node.worldMatrix) }
glass.applyFabricateMaterial(node.renderable!!.material)

bundle.sceneGraph.updateWorldTransforms()
bundle.sortTransparentMeshes()
bundle.present(renderer = renderer)

a tiled floor with an opaque brown torus on the left and a large translucent teal one on the right, the floor tiles clearly visible through the teal ring's lower band. Two translucent spheres sit between them, a blue-grey one behind and a warm tan one in front, overlapping: the tan reads through the blue-grey, and both read through the teal ring in front of them, so three layers of blending are legible at once

Lights and skybox

The scene graph carries a Three.js-style light list: one shadow-casting directional key light, plus point and spot lights up to bundle.maxLights, which is 64, all evaluated in the deferred lighting pass. It can also draw the loaded environment map as the scene background instead of a flat colour.

Lights are pure scene state; an attached bundle uploads them to the lighting pass on the next frame after any change (bundle.syncLights() does it at once). The first shadow-casting directional light becomes the key light that drives the shadow map, or the first directional light of any kind if none of them cast shadows; every other light is shaded without shadows, and syncLights() takes only the first maxLights of them. bundle.showEnvironmentBackground() turns the equirect environment into the backdrop.

val bundle = StandardRenderer.build(
    renderer,
    StandardRenderer.Config(environmentTexture = environment, environmentStrength = 0.85f),
)

// Environment as backdrop (Three.js scene.background = envMap).
bundle.showEnvironmentBackground(true)

// One shadowed key light + coloured point lights that pool on the floor.
bundle.addLight(FabricateLight.directional(Vector3f(-0.4f, -1f, -0.3f), intensity = 1.7f, castShadow = true))
bundle.addLight(
    FabricateLight.point(
        Vector3f(-1.7f, 0.15f, -1.2f),
        color = Vector3f(1f, 0.22f, 0.25f),
        intensity = 10f,
        range = 5.5f,
    ),
)
bundle.addLight(
    FabricateLight.point(Vector3f(1.7f, 0.1f, -1.0f), color = Vector3f(0.25f, 0.5f, 1f), intensity = 10f, range = 5.5f),
)
bundle.addLight(FabricateLight.spot(Vector3f(0f, 2f, 0f), Vector3f(0f, -1f, 0f), intensity = 12f, range = 8f))
bundle.syncLights()

Point lights use inverse-square falloff clamped by range; spot lights add an inner/outer cone. Metals show these as sharp coloured highlights (they have no diffuse term); dielectrics such as the checkerboard floor catch the coloured pools.

the Venice Sunset environment map drawn as the scene backdrop, not a flat colour: a blurred paved square with an ochre palazzo, a stone pine and an arcaded building under a pale evening sky. Three chrome torii stand on a checkerboard of translucent tiles laid over the paving. Their mirror surfaces carry the buildings and the sky, the middle ring takes the brightest highlight, and the tiles pick up pink and blue from the point and spot lights

Post stack

The post stack is Fabricate's small EffectComposer equivalent. It starts with the transparent composite and, by default, runs motion blur then FXAA:

println(bundle.postStack.names()) // [motion-blur, aa]
println(bundle.postStack.output()) // the texture bundle.present(...) will use

Append a fullscreen shader effect with addShaderEffect. The fragment shader receives the previous stack output as sourceTex; the new pass becomes the stack output.

bundle.addShaderEffect(
    "vignette-warmth",
    """
    #version 450
    layout(location = 0) in vec2 vUv;
    layout(set = 0, binding = 0) uniform sampler2D sourceTex;
    layout(location = 0) out vec4 outColor;

    void main() {
        vec4 c = texture(sourceTex, vUv);
        vec2 p = vUv * 2.0 - 1.0;
        float vignette = 1.0 - smoothstep(0.18, 1.05, dot(p, p));
        vec3 warm = c.rgb * vec3(1.18, 1.04, 0.84);
        vec3 lifted = pow(max(warm, vec3(0.0)), vec3(0.88));
        vec3 graded = mix(c.rgb, lifted, 0.72) * mix(0.38, 1.22, vignette);
        outColor = vec4(graded, c.a);
    }
    """
        .trimIndent(),
    renderer = renderer,
)

bundle.present(renderer = renderer)

a magenta torus and a teal one in front of it, with a floor of grey tiles. The vignette pass darkens the frame towards its corners and warms what it leaves, so the tiles nearest the middle stay bright and the rest of the image falls away to black.

Tone mapping and bloom

Deferred lighting runs in HDR, so raw output clips bright highlights to white. Tone mapping compresses HDR into a displayable range, and bloom bleeds the brightest regions into a soft glow.

bundle.setToneMapping(...) appends a tone-map pass that always runs last (options: NONE, REINHARD, FILMIC, ACES, AGX, plus an exposure multiplier). bundle.addBloom(...) inserts a bright-pass, separable blur, and additive composite ahead of the tone-map pass, so bloom accumulates on HDR values before they are compressed. Both modify the post stack, and a bundle already attached to a renderer picks the change up itself.

bundle.addBloom(threshold = 1.0f, intensity = 0.85f, radius = 3.2f)
bundle.setToneMapping(FabricateToneMap.ACES, exposure = 1.0f)
bundle.attachTo(renderer)
bundle.present(renderer = renderer)

The comparison below is the same strongly lit scene captured twice. On the left is the raw HDR output with no tone mapping: every floor tile clips to flat white, and the brightest parts of the rings go white too. On the right, with bloom and ACES, the tiles keep a little shading, each one carries a soft halo, and the rings hold more of their colour.

the same pink and blue metal torii over a floor of white tiles, rendered twice side by side. Left, raw HDR: the tiles are flat white with hard edges and the rings are bright and pale. Right, bloom with ACES tone mapping: the tiles are slightly shaded and each has a soft glow around its edges, and the rings are a little deeper in colour on a darker background

glTF loading and animation

The GLTF path uses the existing Assimp-backed loader, then imports mesh instances into the Fabricate scene graph. Opaque meshes go to the g-buffer/shadow pair. Alpha materials go to the blended transparency pass.

The example below loads the Khronos ToyCar sample: three mesh/material buckets, 77k vertices, 326k indices, and texture slots for base/diffuse, normal, emissive, packed occlusion/roughness/metalness, and clearcoat inputs. The StandardRenderer material bridge consumes base-color textures, UVs, glTF tangent frames, alpha/transmission classification, metalness, roughness, tangent-space normal maps, packed metal/roughness maps, occlusion, emissive maps, clearcoat factors/maps, shadows, AO, and equirectangular image-based lighting from float EXR environments with GGX-prefiltered radiance mips. The headlamp lens ridges depend on the normal map being sampled in the model's tangent basis. Texture transforms (KHR_texture_transform) are applied as one offset and scale per material: the cloth's base colour and normal map repeat three times while its occlusion map does not, as the file specifies. A rotated transform, or two different ones in one material, is logged and sampled with the first. It does not yet handle physical transmission/refraction, sheen, BRDF LUT integration, cubemap PMREM, or clearcoat roughness/normal texture variants. registerGltf logs which of a file's glTF extensions it did not evaluate; for ToyCar that is transmission and sheen.

Comparing the ToyCar capture with three.js

The reference for this capture is Don McCurdy's glTF Viewer with the same ToyCar model and Venice Sunset environment, used for exposure, contrast, fabric detail, and material readability. Its baseline settings are:

  • Environment: Venice Sunset EXR
  • Environment processing: Three.js PMREMGenerator.fromEquirectangular(...)
  • Tone mapping: Linear
  • Exposure: 0
  • Punctual lights: on
  • Ambient intensity: 0.3
  • Direct intensity: 0.8 * PI
  • Background: #191919

ToyCar in Don McCurdy's glTF Viewer, close comparison framing

The grime and surface variation are in the GLB's own texture maps; neither viewer adds procedural dirt. StandardRendererDocsHarness renders the ToyCar capture at the reference's 50-degree field of view, from the same flatter near-roofline vantage, so the two framings match.

ToyCar comparison between the throwaway Three.js probe and Field StandardRenderer

Loading it into a bundle:

/*
A repository-relative path, because this page's asset ships with the docs and the capture runs
from the repository root. In a document of your own, name the asset and let Field resolve it:
    val toyCarPath = "ToyCar.glb".absolutePath
which looks inside the document folder first and the workspace project second. See
"Where Field looks for a file" under Working in a Document.
*/
val toyCarPath = File("public/docs/assets/fabricate_demo/ToyCar.glb").absolutePath
val loaded = bundle.registerGltf(
    path = toyCarPath,
    namePrefix = "robot",
    renderer = renderer,
)

// The imported nodes carry bind-pose transforms and any sampled TRS animation.
bundle.sceneGraph.updateWorldTransforms(time = 0f)

// Advance sampled node animation and keep transparent draw order current.
loaded.animate(time = 1.25f)
bundle.sortTransparentMeshes()

println("opaque meshes: ${loaded.opaqueMeshes.size}")
println("transparent meshes: ${loaded.transparentMeshes.size}")

the Khronos ToyCar sample, a dark green 1950s two-door coupe seen from the front three-quarter against black. An orange flame decal runs back from the headlights and a black-and-white chequer stripe follows it along the hood. The bumper, grille surround and side spears are chrome, the tyres are whitewall over mirrored hubcaps, and the tinted glass shows a red interior. It stands on a crumpled dark red cloth

You can override material classification on import:

val model = GltfSupport.loadGltfBasic(toyCarPath)
bundle.sceneGraph.importGltf(model, namePrefix = "ghost") { mesh ->
    FabricateMaterial.fromGltf(mesh.material).transparent(0.42f)
}

For tooling that wants only the pure scene representation, FabricateGltfBridge.fromModel(model) returns a FabricateScene without registering renderer meshes. Use bundle.registerGltf(...) when you want GPU draw resources and FabricateGltfBridge when you want to inspect or plan imported nodes first.

The environment asset is already local. Load the EXR directly when the environment should participate in roughness-aware IBL. Ask the texture loader for prefiltered radiance mips so glossy materials can sample lower LODs as roughness rises. The PNG companion is a debug/preview fallback.

// Again a docs-repository path. In a document of your own, drop the EXR into the document folder
// and ask for it by name: "venice_sunset_1k.exr".absolutePath.
val veniceExr = File("public/docs/assets/fabricate_demo/venice_sunset_1k.exr").absolutePath

val environment =
    LoadedTexture.fromExrFile(
            veniceExr,
            renderer,
            requestedMipLevels = 0,
            prefilterEnvironment = true,
        )
        .also {
            it.setDefaultSampler()
            it.transferNow()
        }

val bundle = StandardRenderer.build(
    renderer,
    StandardRenderer.Config(
        environmentTexture = environment,
        environmentStrength = 1.35f,
        environmentRotation = 0.42f,
        // Linear renderer input for a #191919-style sRGB background.
        backgroundColor = Vector3f(0.0093f, 0.0093f, 0.0093f),
    ),
)

Metalness

The two torii use the same base color and roughness. The left one is dielectric; the right one is metallic. Keeping the geometry fixed makes the material response visible.

baseColor and roughness stay fixed; only metallic changes:

left.gBuffer.mesh.push["baseColor"] = Vector4f(0.78f, 0.80f, 0.84f, 1f)
left.gBuffer.mesh.push["metallic"] = 0.0f
left.gBuffer.mesh.push["roughness"] = 0.16f

right.gBuffer.mesh.push["baseColor"] = Vector4f(0.78f, 0.80f, 0.84f, 1f)
right.gBuffer.mesh.push["metallic"] = 1.0f
right.gBuffer.mesh.push["roughness"] = 0.16f

two torii of the same pale blue-grey base colour and the same roughness, side by side against black. The dielectric one takes a broad soft highlight across its upper right and a wide bright crescent along the inside of its lower left. The metallic one, under the same light, keeps only two small hard glints, at the top and inside the lower edge, and the rest of the ring stays dark

Shadows

The left side disables shadowing. The right side uses the default shadowed scene. Look for the contact shadow under the object, not for a dramatic lighting change.

The comparison is a single scene captured twice:

bundle.scene.aoStrength = 0f
bundle.scene.ambientColor.set(0.015f, 0.015f, 0.018f)
bundle.scene.lightIntensity = 1.35f

bundle.scene.shadowStrength = 0f

// capture the unshadowed scene

bundle.scene.shadowStrength = 1f

// capture the shadowed scene

one dark olive torus standing on a large pale grey sphere used as the ground, rendered twice side by side. Unshadowed, the ring meets the ground with nothing beneath it and the two shapes read as separate. Shadowed, a soft dark ellipse lies on the ground offset from where the ring touches down, and that contact is the whole of the difference: the lighting on the ring itself is unchanged

Ambient occlusion

The left side disables ambient occlusion; the right side turns it on. Both show the same cluster of matte spheres resting together on the floor. AO only modulates the ambient term here (direct light is never occluded), so its effect is clearest in the contact valleys between touching spheres, where the surfaces are starved of direct light and lit mostly by ambient. Turning AO on darkens those valleys from a soft grey to a firm crease.

AO is also just a scene parameter in this harness:

val originalAoStrength = bundle.scene.aoStrength

bundle.scene.aoStrength = 0f

// capture the AO-off scene

bundle.scene.aoStrength = originalAoStrength

// capture the AO-on scene

six matte grey spheres resting together in two rows on a large pale dome, rendered twice side by side. Without ambient occlusion the spheres cast soft drop shadows onto the dome but the seams where they touch each other stay the same grey as the rest of them. With it, those contact valleys darken to firm creases while the lit tops of the spheres are unchanged