Textured Geometry Pipeline#
This page documents how dk64_lib turns DK64 geometry display lists into
OBJ, MTL, glTF, GLB, DAE, and PNG files. It is intended to be a human-readable
map of the current implementation rather than a replacement for the API
reference.
The relevant implementation lives primarily in:
dk64_lib.data_types.geometry.GeometryDatadk64_lib.f3dex2.texture_export.TexturedObjExporterdk64_lib.f3dex2.texture_export.TexturedGltfExporterdk64_lib.f3dex2.texture_export.TexturedDaeExporterdk64_lib.f3dex2.texture_export.decode_texturedk64_lib.f3dex2.commandsdk64_lib.f3dex2.vertex.Vertex
High-Level Export Flow#
Textured geometry export starts at one of the high-level helpers:
Rom.export_all()Rom.export_geometries()GeometryData.save_to_obj()GeometryData.save_to_textured_obj()GeometryData.create_textured_obj()GeometryData.save_to_gltf()GeometryData.save_to_glb()GeometryData.create_textured_gltf()GeometryData.create_textured_glb()GeometryData.save_to_dae()GeometryData.create_textured_dae()
Geometry exports include textures by default. Rom.export_geometries() and
Rom.export_all() write GLB files unless the caller selects another
geometry_format. The explicit GeometryData.save_to_obj(),
save_to_gltf(), save_to_glb(), and save_to_dae() helpers also keep
include_textures=True as their default.
The export flow is:
GeometryDataparses a geometry table entry into display lists, vertex data, display-list chunk metadata, and expansion display lists.The matching
GeometryData.create_textured_*helper fetches the geometry texture table throughrom.get_geometry_texture_data().TexturedObjExporter,TexturedGltfExporter, orTexturedDaeExporterwalks the geometry display lists, tracks the active F3DEX2 texture state, and groups triangles by the texture that was active when those triangles were emitted.The exporter writes OBJ text plus MTL text, glTF JSON plus binary data, a GLB binary, or an in-memory DAE document, and the PNG files needed by the materials.
save_textured_obj_export()writes the OBJ, MTL, and PNG files to disk.save_textured_gltf_export()writes the glTF, binary buffer, and PNG files.save_textured_glb_export()writes the GLB file.save_textured_dae_export()writes the DAE and PNG files.
The production geometry exporters do not write packed mipmap base/reference
images. Those base images are only written by test_mipmap_export(), which is
a temporary visual debugging helper.
Display-List Texture State#
F3DEX2 display lists are stateful. A triangle does not directly say which image it uses. Instead, the exporter watches texture-related commands and reconstructs the texture state that is active when a triangle command is encountered.
The current state tracker pays attention to these commands:
Command |
How export uses it |
|---|---|
|
Records the pending image index plus the image |
|
Associate the pending image with the command’s tile number. The most recently loaded tile is also remembered as a fallback. |
|
Treats the pending image as a palette image. Color-indexed textures use this palette when decoded. |
|
Records a tile descriptor: |
|
Records the tile dimensions. F3DEX2 tile coordinates are quarter-texel
values, so export computes |
|
Selects the active tile when texturing is on. If texturing is disabled, subsequent faces are exported without texture coordinates or a material. |
|
Recurses into a branch display list with a cloned texture state, so branch-local changes do not unexpectedly mutate the parent traversal. |
When the exporter has an active tile, a tile descriptor, tile dimensions, and a loaded image source, it can build a texture key:
tex_<image_index>_pal_<palette_index|none>_f<fmt>_s<size>_<width>x<height>
For example:
tex_158_pal_159_f2_s1_32x32
That material name is used consistently in the OBJ, MTL, and texture filenames.
Mesh Grouping#
OBJ files are easier to import when faces that share a material are grouped
together. TexturedObjExporter therefore creates internal mesh groups while
walking each display list.
Groups are split when:
a new
G_VTXcommand loads a different vertex buffer;triangle output switches to a different active texture;
a branched display list yields its own groups.
The current triangle path handles G_TRI1 and G_TRI2. G_TRI2 expands
to two triangles. Faces emitted while no texture is active are still exported,
but they do not receive vt coordinates or a usemtl statement.
Vertex Output and Vertex Colors#
DK64 vertices are 16 bytes:
Byte range |
Field |
Export use |
|---|---|---|
|
|
OBJ vertex position. |
|
|
OBJ vertex position. |
|
|
OBJ vertex position. |
|
|
Parsed and preserved in |
|
|
Used to compute OBJ |
|
|
Used to compute OBJ |
|
|
Red vertex color channel. |
|
|
Green vertex color channel. |
|
|
Blue vertex color channel. |
|
|
Parsed and preserved. OBJ export currently omits alpha. glTF, GLB, and DAE export write it in the vertex color source. |
OBJ vertex color is written using the common extended OBJ form:
v <x> <y> <z> <red> <green> <blue>
The color channels are normalized from 0..255 to 0.000000..1.000000.
For example, a vertex with red 255, green 0, and blue 128 exports as:
v 0 0 0 1.000000 0.000000 0.501961
This is useful in tools such as Blender, which can read vertex colors from OBJ
files even though color support is not part of the oldest Wavefront OBJ
specification. Alpha is available in the parsed Vertex object and in glTF,
GLB, and DAE export, but OBJ export writes RGB only.
glTF and GLB export write COLOR_0 accessors as RGBA vertex colors. RGB
channels are converted from DK64’s byte values into linear floats before export,
because glTF vertex colors are material multipliers rather than sRGB texture
samples. Alpha is normalized directly from 0..255 to 0.0..1.0. This
prevents mid-range vertex colors from rendering too bright in glTF viewers such
as Blender while preserving vertex alpha for blend effects.
DAE export currently writes a COLOR source with normalized RGBA values.
Textured materials still use the decoded texture as the diffuse map; the vertex
color stream is exported as geometry data rather than baked into the PNGs.
UV Mapping#
F3DEX2 stores per-vertex texture coordinates as signed 16-bit fixed-point
values. The current exporter interprets them with 5 fractional bits, so one
texel is 32 units.
The OBJ and DAE UV conversion is:
u = signed_16(texture_cord_u) / 32 / texture_width
v = 1 - (signed_16(texture_cord_v) / 32 / texture_height)
The glTF and GLB UV conversion uses the same u value, but writes the source
v value without the OBJ/DAE vertical-axis flip:
u = signed_16(texture_cord_u) / 32 / texture_width
v = signed_16(texture_cord_v) / 32 / texture_height
Important details:
Coordinates are clamped to
0..1only when the activeG_SETTILEstate has the N64 clamp bit set for that axis. Otherwise values outside0..1are preserved so normal texture wrapping/repeating can still work in import tools.OBJ and DAE invert the vertical texture axis to match those importer conventions. glTF and GLB keep the source vertical axis because Blender’s glTF importer applies the expected image orientation for that format.
Texture width and height come from the active
G_SETTILESIZEcommand, not from the raw byte count.G_TEXTUREscale values are parsed by the command class but are not applied by OBJ, glTF, GLB, or DAE export today.
If a vertex has texture_cord_u = texture_width * 32 then it maps to
u = 1.0. If it has texture_cord_v = texture_height * 32 then it maps to
v = 0.0 after the exported vertical-axis flip.
OBJ and MTL Structure#
Every textured OBJ starts with an MTL reference:
mtllib geometry.mtl
Each mesh group writes:
a comment identifying the mesh group and display-list offset;
vlines for all vertices, including RGB vertex colors;vtlines when the group has an active texture;usemtlbefore textured faces;flines usingvertex_index/texture_indexpairs for textured faces;plain
flines for untextured faces.
The MTL file creates one material per unique texture key:
newmtl tex_158_pal_159_f2_s1_32x32
Ka 1.000000 1.000000 1.000000
Kd 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
d 1.000000
illum 1
map_Kd textures/tex_158_pal_159_f2_s1_32x32.png
If the decoded highest-resolution texture contains any transparent pixels, the
exporter also writes a dedicated alpha-mask PNG and references it with
map_d. The mask stores opacity in both RGB and alpha channels: black means
transparent, white means opaque. This is more compatible than pointing
map_d at the color PNG, because some OBJ importers read opacity from image
luminance rather than from the PNG alpha channel.
MTL can describe opacity maps, but it cannot describe Blender’s
Render Method setting. When a textured OBJ export contains transparent
materials, dk64_lib therefore writes a companion Blender script named after
the MTL file, for example 034_DK_Isles_Overworld.blender.py. After importing
the OBJ into Blender, run that script from Blender’s Text Editor to switch only
the transparent DK64 materials to Blended. The script sets
surface_render_method = "BLENDED" on Blender 4.2 and newer and falls back to
blend_method = "BLEND" for older Blender versions.
newmtl tex_0_pal_none_f0_s2_2x2
Ka 1.000000 1.000000 1.000000
Kd 1.000000 1.000000 1.000000
Ks 0.000000 0.000000 0.000000
d 1.000000
illum 4
map_Kd textures/tex_0_pal_none_f0_s2_2x2.png
map_d textures/tex_0_pal_none_f0_s2_2x2_alpha.png
If either texture axis is clamped by G_SETTILE, the material name is
suffixed with _clamp_s, _clamp_t, or _clamp_st. The MTL texture map
statements also include the standard Wavefront -clamp on option as a hint
for importers that honor it:
newmtl tex_0_pal_none_f0_s2_2x2_clamp_st
map_Kd -clamp on textures/tex_0_pal_none_f0_s2_2x2_clamp_st.png
When a packed mipmap texture is detected, the exporter writes all decoded mip
levels as PNG files, but map_Kd points at the highest-resolution level. For
example, the files may include:
textures/tex_158_pal_159_f2_s1_32x32.png
textures/tex_158_pal_159_f2_s1_32x32_mip1_16x16.png
textures/tex_158_pal_159_f2_s1_32x32_mip2_8x8.png
textures/tex_158_pal_159_f2_s1_32x32_mip3_4x4.png
Only the highest-resolution color PNG is referenced by map_Kd. If the
texture has transparent pixels, the generated *_alpha.png mask is
referenced by map_d.
glTF and GLB Structure#
GeometryData.save_to_glb() writes single-file binary glTF. This is the
preferred Blender-focused export path because the geometry, material
description, binary buffers, and referenced texture PNGs are packaged into one
.glb file.
GeometryData.save_to_gltf() writes separate glTF assets. It returns the
paths written with the .gltf file first, the sidecar .bin buffer second,
and texture PNG files after that. This form is useful when the JSON and texture
files need to be inspected directly.
The glTF/GLB exporter uses the same display-list traversal, mesh grouping, texture decoding, UV conversion, clamp handling, and packed mipmap detection as the OBJ exporter. The file structure is:
each mesh group becomes one glTF mesh and one scene node;
each mesh primitive contains
POSITION,COLOR_0, and, when textured,TEXCOORD_0attributes;indices are written as unsigned integer accessors into the binary buffer;
each unique texture key becomes one glTF texture, image, and sampler, plus one or more materials when opaque and vertex-alpha groups need different blend modes;
materials use the
KHR_materials_unlitextension because DK64 map textures are closer to unlit game materials than to authored PBR materials;the material base color texture references the highest-resolution decoded PNG;
transparent textures keep their alpha in the color PNG and set
alphaModetoBLEND;mesh groups with vertex alpha below full opacity also set
alphaModetoBLENDsoCOLOR_0alpha can create DK64-style foliage and terrain fades even when the texture image itself is opaque;when the same opaque texture is used by both opaque and vertex-alpha mesh groups, the exporter writes a separate
*_vertex_alphamaterial variant for the blended groups;clamped DK64 tile axes are written as glTF sampler
wrapSandwrapTvalues, usingCLAMP_TO_EDGEfor clamped axes andREPEATotherwise.
Separate .gltf export writes decoded packed mipmap levels beside the
highest-resolution texture PNG, but only the highest-resolution image is
referenced by the glTF material. .glb embeds the highest-resolution PNG
needed by each material and does not add unreferenced mip images to the binary
container.
For batch exports, Rom.export_geometries(..., geometry_format="glb") writes
.glb files, and geometry_format="gltf" writes separate glTF assets.
Rom.export_all() accepts the same geometry_format option and passes it
through to geometry export.
DAE Structure#
GeometryData.save_to_dae() writes textured DAE output by default. It returns
the paths written, with the DAE file first and the companion PNG texture files
after it. Pass include_textures=False to keep the legacy geometry-only DAE
path.
The textured DAE exporter uses the same display-list traversal, mesh grouping, texture decoding, UV conversion, clamp handling, and packed mipmap detection as the OBJ exporter. The file structure is different:
each mesh group becomes one
library_geometriesentry;each geometry contains aligned
VERTEX,COLOR, and, when textured,TEXCOORDsources;each textured triangle set binds its material symbol to the geometry
TEXCOORDsource throughbind_vertex_input;each unique texture key becomes one DAE material and effect;
each material’s diffuse channel references the highest-resolution decoded PNG, matching OBJ’s
map_Kdbehavior;transparent textures add a separate alpha-mask image and reference it from the DAE
transparentchannel;clamped DK64 tile axes are written as
wrap_sandwrap_tsampler hints withCLAMPorWRAPvalues.
Packed mipmap levels are still exported as PNG files beside the highest resolution image, but the DAE material references the highest-resolution PNG. That mirrors the OBJ path and avoids relying on importer-specific mipmap-chain extensions.
For batch exports, Rom.export_geometries(..., geometry_format="dae") writes
.dae files instead of .obj files. Rom.export_all() accepts the same
geometry_format option and passes it through to geometry export.
Animated DAE Texture Atlases#
Some DK64 materials are expected to animate by swapping through a run of texture frames. The current library does not yet parse a first-class texture-animation table, so DAE animation export is intentionally opt-in: the caller supplies the frame indices that belong to a material’s first frame.
For a texture whose geometry display list references texture index 31, and
whose animation frames live at texture indices 31 through 38, export a
DAE preview like this:
rom.geometry_tables[1].save_to_dae(
"funkys_store.dae",
"dk64_export/geometries",
animated_texture_frames={31: range(31, 39)},
animation_frame_duration=4,
)
The same option is available on ROM-level DAE export:
rom.export_geometries(
"dk64_export/geometries",
geometry_format="dae",
animated_texture_frames={31: range(31, 39)},
animation_frame_duration=4,
)
Each mapping key is the base texture index used by the geometry. Each value is
the ordered list of frame texture indices. When a color-indexed animation needs
different palettes per frame, use (texture_index, palette_index) tuples in
the frame list. Plain integer frame entries reuse the palette from the material
referenced by the display list.
For each animated material, the DAE exporter:
decodes each supplied frame with the material’s
fmt,size, width, and height;stitches the frames horizontally into one atlas named
<material_name>_anim_<count>frames.png;writes an
*_alpha.pngatlas mask when any frame contains transparent pixels;points the DAE material at the stitched atlas instead of the standalone frame PNG;
scales the DAE
TEXCOORDSvalues by1 / frame_countso the static DAE import displays frame 0 correctly;writes
animated_textures.blender.pybeside the DAE export.
Run animated_textures.blender.py after importing the DAE into Blender. The
script rebuilds the affected materials around the stitched atlas and keyframes a
constant-stepped UV offset. This is Blender-specific support code because normal
DAE material import does not reliably preserve or evaluate animated texture
sampler state.
Texture Decoding#
decode_texture() converts raw texture bytes into RGBA pixels before the PNG
writer runs. The decoder currently supports these N64 format/size combinations:
|
|
Meaning |
|---|---|---|
|
|
RGBA16. |
|
|
RGBA32. |
|
|
CI4, using a palette decoded as RGBA16 entries. |
|
|
CI8, using a palette decoded as RGBA16 entries. |
|
|
IA4. |
|
|
IA8. |
|
|
IA16-style intensity/alpha pairs. |
|
|
I4. |
|
|
I8. |
|
|
16-bit storage where the high byte is used as intensity. |
Unsupported or incomplete texture data decodes to a black and magenta checkerboard placeholder. That keeps full ROM export robust: one unsupported texture does not abort every geometry export.
Palette Handling#
Color-indexed textures use G_LOADTLUT to identify the palette source. The
palette is decoded as up to 256 RGBA16 entries. CI4 textures use the high nibble
then the low nibble from each byte. CI8 textures use each byte as one palette
index.
The material name includes the palette index because the same texture bytes can produce different colors with a different palette:
tex_896_pal_897_f2_s0_64x64.png
Packed Mipmap Detection#
Some DK64 texture table entries contain several mipmap levels packed into one raw texture blob. The display list usually describes the highest-resolution tile, but the raw byte count is larger than that tile alone. The exporter uses that extra storage to decide whether a known packed layout is present.
Detection happens before normal texture decoding. A packed decoder is used only when:
the active texture’s
fmt,size, width, and height match a known layout;the raw texture has enough texels for that layout;
any required palette data can be fetched, or placeholder palette entries can be tolerated.
If those checks fail, export falls back to a single decoded PNG.
The production exporter writes decoded mip levels, not the raw packed base
image. The raw base image is useful for reverse engineering and is available
through test_mipmap_export().
Packed Mipmap Layouts#
The packed mipmap code works in decoded RGBA pixels, but the storage math is based on source texels. The descriptions below use “pixel” to mean one decoded texel after CI/RGBA conversion.
Half-swap means that a fixed-width group is split into two equal halves and the halves are swapped. For example, a 16-pixel group:
00 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15
becomes:
08 09 10 11 12 13 14 15 00 01 02 03 04 05 06 07
Smaller groups use the same rule. An 8-pixel group swaps two 4-pixel halves. A 4-pixel group swaps two 2-pixel halves.
RGBA16 32x32#
Example texture: tex_2_pal_none_f0_s2_32x32.
Raw packed dimensions are normally interpreted as 32x43 pixels. Exported
levels are:
32x32
16x16
8x8
4x4
Layout:
Level 0 is the first
32 * 32pixels. Odd rows are half-swapped in 4-pixel groups.Level 1 starts after level 0. Each 32-pixel storage row contains two 16-pixel output rows. The second row is half-swapped in 4-pixel groups.
Level 2 stores four 8-pixel output rows in each 32-pixel storage row. Odd output rows are half-swapped in 4-pixel groups.
Level 3 uses four 4-pixel rows per 16-pixel storage group. Odd output rows are half-swapped in 4-pixel groups.
CI4 32x64#
Example texture: tex_0_pal_1_f2_s0_32x64.
Raw packed dimensions are normally interpreted as 32x92 pixels. Exported
levels are:
32x64
16x32
8x16
4x8
Layout:
Level 0 is the first
32 * 64pixels. Odd rows are half-swapped in 16-pixel groups.Level 1 starts after level 0 and is stored as a flat 16x32 image. Odd rows are half-swapped in 16-pixel groups.
Level 2 starts after level 1. Each 32-pixel storage row contributes two 8-pixel output rows: the first 8 pixels, then skip 16 pixels, then the next 8 pixels.
Level 3 starts after level 2. Each 32-pixel storage row contributes two 4-pixel output rows: the first 4 pixels, then skip 20 pixels, then the next 4 pixels.
CI4 64x32#
Example texture: tex_208_pal_209_f2_s0_64x32.
Raw packed dimensions are normally interpreted as 64x43 pixels. Exported
levels are:
64x32
32x16
16x8
8x4
Layout:
Level 0 is the first
64 * 32pixels. Odd rows are half-swapped in 16-pixel groups.Level 1 starts after level 0. Each 64-pixel storage row contains two 32-pixel output rows. The second row is half-swapped in 16-pixel groups.
Level 2 stores four 16-pixel output rows in each 64-pixel storage row. Rows 1 and 3 are each half-swapped as one 16-pixel group.
Level 3 uses row offsets
0,24,32, and56inside each 64-pixel storage row. This means it reads 8 pixels, skips 16, reads 8, reads 8, skips 16, and reads 8.
CI4 32x32#
Example texture: tex_1272_pal_1273_f2_s0_32x32.
Raw packed dimensions are normally interpreted as 32x46 pixels. Exported
levels are:
32x32
16x16
8x8
4x4
Layout:
Level 0 is the first
32 * 32pixels. Odd rows are half-swapped in 16-pixel groups.Level 1 starts after level 0 and is stored as a flat 16x16 image with no additional row swapping.
Level 2 starts after level 1. Each 32-pixel storage row contributes two 8-pixel output rows: read 8, skip 16, read 8.
Level 3 starts after level 2. Each 32-pixel storage row contributes two 4-pixel output rows: read 4, skip 20, read 4.
CI8 32x32#
Example texture: tex_158_pal_159_f2_s1_32x32.
Raw packed dimensions are normally interpreted as 32x43 pixels. Exported
levels are:
32x32
16x16
8x8
4x4
Layout:
Level 0 is the first
32 * 32pixels. Odd rows are half-swapped in 8-pixel groups. In practice that means each odd row is split into four 8-pixel groups, and each group swaps its 4-pixel halves.Level 1 starts after level 0 and is stored as a flat 16x16 image. Odd rows are half-swapped in 8-pixel groups.
Level 2 starts after level 1. Each 32-pixel storage row contains four 8-pixel output rows. Rows 0 and 2 are read as-is. Rows 1 and 3 are half-swapped in 8-pixel groups.
Level 3 starts after level 2. Each 32-pixel storage row uses row offsets
0,12,16, and28. This corresponds to: read 4 pixels, skip 8, read 4, read 4, skip 8, read 4. There is no swapping at this level.
Test Mipmap Export Helper#
test_mipmap_export() is a visual reverse-engineering helper. It is not used
by normal OBJ, glTF, GLB, or DAE export.
It writes:
a raw base/reference PNG named
*_base_<width>x<height>.png;the decoded highest-resolution PNG;
decoded
mip1,mip2, andmip3PNGs for known layouts;optional reference textures that have been useful while researching DK64 mipmap packing.
This helper intentionally remains in the library for now because the mipmap decoding work is still being validated against real assets. The long-term plan is to remove the ad hoc helper once the remaining reverse-engineering cases are covered by normal unit tests and production code.
Full ROM Export#
Rom.export_all() writes all currently supported outputs into sibling
folders:
exports/
geometries/
textures/
text/
cutscenes/
assets/
Geometry exports use textured GLB output by default. Pass
geometry_format="obj", geometry_format="gltf", or
geometry_format="dae" to Rom.export_geometries() or Rom.export_all()
to write another geometry format instead. Rom.export_textures() separately
writes PNG files for geometry textures referenced by display lists. It uses the
same texture-state reconstruction described on this page, so those PNGs have
reliable dimensions from display-list format, size, palette, width, and height.
For table 7, table 14, and unreferenced table 25 entries, Rom.export_textures()
also writes best-effort RGBA5551 PNGs when the decompressed byte length matches
a known size guess. Guessed outputs include guess in their filenames. Use
Rom.export_assets() or Rom.export_raw_tables() when you need raw bytes
from texture tables.
Testing Coverage#
The texture, OBJ, glTF/GLB, and DAE behavior is covered in
tests/test_texture_export.py.
Important coverage includes:
textured OBJ material and PNG output;
textured glTF material, sampler, alpha, binary buffer, and PNG output;
GLB chunking and embedded texture output;
textured DAE material, sampler, alpha-mask, and PNG output;
OBJ RGB vertex colors and glTF/GLB/DAE RGBA vertex colors;
UV generation from vertex texture coordinates;
material grouping by active texture state;
palette-based CI4 and CI8 decoding;
RGBA, IA, and I texture decoding;
packed mipmap exports for RGBA16 32x32, CI4 32x64, CI4 64x32, CI4 32x32, and CI8 32x32;
ensuring production geometry export does not write raw
*_base_*mipmap images;ensuring
test_mipmap_export()does write base/reference images.
Run the focused texture tests with:
python -m pytest tests/test_texture_export.py -q
Run the whole suite with:
python -m pytest -q
Known Limitations#
The current exporter is intentionally conservative:
It supports the packed mipmap layouts that have been identified so far, not every possible N64 layout.
It does not write or reference mipmap chains in the MTL, glTF, GLB, or DAE material. The material references the highest-resolution decoded PNG because that is what normal importers expect.
It parses
G_TEXTUREscale fields but does not currently apply them to OBJ, glTF, GLB, or DAE UVs.It does not auto-detect DK64 animated texture frame ranges. Animated DAE atlas export requires an explicit
animated_texture_framesmapping.It writes RGB vertex colors to OBJ but does not write vertex alpha.
Texture clamp fields are translated to clamped OBJ UVs and
-clamp onMTL texture map hints, to glTF/GLBwrapS/wrapTsampler values, and to DAEwrap_s/wrap_tsampler hints. Mirror, mask, and shift fields are parsed inG_SETTILEbut are not currently translated into exporter behavior.OBJ/MTL importer support for
-clamp onvaries. The exporter emits it because it is part of the Wavefront material syntax, but clamped UVs are the compatibility fallback.OBJ/MTL cannot express Blender’s
Blendedrender method. The generated*.blender.pyscript is the Blender-specific workaround.Unsupported formats fall back to a placeholder image instead of failing the export.
These limitations are good candidates for future refactors, especially before the eventual Rust rewrite. The current tests should be treated as behavioral documentation for the layouts and export decisions described on this page.