Repository navigation
Share vertices for flat shaded materials - #741
Open
Taldariner wants to merge 2 commits into
Open
Taldariner wants to merge 2 commits into
Taldariner wants to merge 2 commits into
Conversation
…identity Preparation for flat shading vertex sharing, no change in output. vertexBufferTriangles entries become (vertices, flag) so the triangle commands can say which vertex provides the shade of a flat shaded triangle; addFace still always passes 0. Picking the vertices a face draws with moves into assignFace, which is the part flat shading will replace. createTriangleCommands now indexes the buffer by object identity, falling back to equality, and no longer deep copies the triangle list only to look the copies up by value. That is a plain win on large meshes and it stays correct once the optimization starts handing out vertices that compare equal to each other.
With G_SHADING_SMOOTH cleared the microcode takes the shade of a triangle from one
vertex, selected by the flag argument of the triangle commands. The shade of the
other two corners is never read, so vertices that differ only in shade can be
merged as long as every triangle keeps one corner carrying its own shade. The
exporter wrote one vertex per normal and always passed flag 0, so a faceted model
cost far more vertex loads than it had to.
flat_shading.py picks the provoking corner per triangle and tracks which buffer
vertices may be shared. Two steps: shades are matched to keys they can own (a
per-face choice alone ends at 9 vertices for a cube instead of 8), then faces are
processed in export order, reusing a vertex whenever its shade is already right or
has not been read yet.
Skinned meshes are included. A parent limb's vertices arrive as existingVertData
already written out, so they enter the pool claimed: reusable as a corner, allowed
to provoke only when their shade already matches, never rewritten. Visibility of
those follows the same material region rule as vertInBuffer, and after a buffer
flush the pool is refilled from them alone. OOTTriangleConverter opts out, because
OoT skinning computes skin vertex normals separately and stores them in
SkinLimbModif structures that this does not model.
getInfoDict compares edges without shade for flat materials, so saveTriangleStrip
keeps faces that can share vertices next to each other.
Behind scene.optimizeFlatShading, off by default. Enabled for every F3DEX
generation, which is exactly where the triangle commands encode flag as a cyclic
rotation of the indices. Original F3D writes it as a separate byte and stays out.
The behaviour relied on is spelled out by F3DEX3 itself, whose microcode is public:
tri_flat_shading merges RGB from the original vertex 1 into all three corners and
keeps each corner's alpha, and its gbi.h documents the same. Clipping is handled
there by flatV1Offset, so the provoking vertex survives it.
Measured on a generated corpus exported to C, F3DEX, F3DEX2 and F3DEX3, with and
without material bleeding:
hexagonal prism (16 tris) 30 -> 12 vertices
Bob-omb top (20 tris) 42 -> 16
cube 24 -> 8
two bones, one mesh (skinned) 54 -> 36
large texture mode 280 -> 152, 24 -> 13 vertex loads
12800 triangle mesh 26074 -> 13647, 848 -> 428 vertex loads
Insertable binary export was checked the same way for F3DEX and F3DEX2: the
display list shrank from 948 to 660 bytes with every index inside the vertex
buffer. Planar meshes, smooth materials and exports with the switch off are
unchanged byte for byte, and the 12800 triangle export came out faster rather than
slower in every run, by a fifth to a third, since half as many vertices means half as
many buffer scans.
A debug switch, scene.poisonFlatShading, writes a deliberately wrong shade into
vertices that provide shade to no triangle, so a wrong provoking vertex shows up as
a magenta or unlit face on hardware instead of a subtle shading difference. On the
corpus it poisons 160 of 416 vertices and the drawn result is unchanged, which is
what the render check on an accurate RDP is meant to confirm.
Dragorn421
reviewed
Oct 10, 2026
Dragorn421
left a comment
Contributor
There was a problem hiding this comment.
Sorry for the review delay.
Overall I'm not convinced by adding tests at this point in fast64's development, the addon is pretty mature. Also not convinced by making a module bpy-less just for the sake of testing it
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the vertex efficiency half of #696. As @Lilaa3 put it there:
What it does
With
G_SHADING_SMOOTHcleared, the microcode takes the shade of a whole triangle from the vertex theflagargument of the triangle commands selects. The shade of the other two corners is never read. The exporter did not use that: it wrote one vertex per normal and always passed flag 0, so a faceted model paid for vertices it did not need.This adds
fast64_internal/f3d/flat_shading.py, which picks the provoking corner of each triangle and tracks which buffer vertices may be shared. Two steps:getInfoDictcompares edges without shade for flat materials, sosaveTriangleStripkeeps faces that can share vertices next to each other.Behind
scene.optimizeFlatShading, off by default.Results
C export, F3DEX2 (F3DEX and F3DEX3 give the same counts), with and without material bleeding:
Planar meshes are untouched, since one normal means nothing to share. The 12800 triangle export also got faster, by a fifth to a third depending on the run: half as many vertices means half as many buffer scans.
Scope
Enabled for every F3DEX generation, which is exactly where the triangle commands encode
flagas a cyclic rotation of the indices. Original F3D writes it as a separate byte and is left out. T3D was not looked at.Skinned meshes are included. A parent limb's vertices arrive as
existingVertDataalready written out, so they enter the pool claimed: reusable as a corner, allowed to provoke only when their shade already matches, never rewritten.OOTTriangleConverteropts out, because OoT skinning computes skin vertex normals separately and stores them inSkinLimbModifstructures this does not model.Why the behaviour is what I say it is
F3DEX3's microcode is public, and it spells the rule out:
with
$v25loaded fromorigV1Addr + flatV1Offset. So RGB comes from vertex 1 and alpha stays per vertex, which is why alpha is part of the merge key here and vertices are never merged across it.flatV1Offsetis what keeps this correct through clipping. GLideN64 reads the flag the same way for the older microcodes, including the separate flag byte of original F3D.How it was checked
A generated corpus of 22 meshes (flat and smooth, vertex colors, fog, UV seams, large texture mode, two materials on one object, F3DEX3 packed normals and cel shading) plus a skinned geolayout export, for F3D, F3DEX, F3DEX2 and F3DEX3, with and without bleeding, exported through the real operators in Blender 4.2.16:
gSPVertexand theflagrule, then compared as multisets up to a cyclic rotation of the corners;There is also a debug switch,
scene.poisonFlatShading, which writes a deliberately wrong shade into vertices that provide shade to no triangle. On the corpus it poisons 160 of 416 vertices and nothing about the drawn result changes, which is the property the optimization rests on.What I could not do: run this on hardware or an accurate RDP. I have no ROM to build a decomp project from. Everything above checks my model of the microcode, and for F3DEX3 that model comes from the microcode source, but F3DEX2 is still only corroborated by GLideN64. If someone with a decomp setup can run it, I have a ready
.blendwith the test shapes and a fixed camera, plus a script that compares two screenshots pixel by pixel; the interesting run is with poisoning on, where a wrong provoking vertex shows up as a magenta or unlit face instead of a subtle shading difference. Happy to attach both.Tests
tests/test_flat_shading.pycovers the assignment: the known optima above, agreement with a brute force optimum on random small meshes, the invariants (the provoking vertex carries its face's shade, corners keep their vertex), alpha staying in the key, the claimed and inherited rules, and that poisoning leaves provoking and inherited vertices and alpha alone.They are plain scripts run by
tests/run_tests.py, not pytest: the repository root is itself a Python package whose__init__.pyimports bpy, so pytest resolves test modules asfast64.tests.…and fails to collect. This keeps the dependency count at zero, matching the rest of the repo..github/workflows/tests.ymlruns them.The Blender level harness that produced the corpus numbers lives outside this PR, since it is larger than the change itself and overlaps with #317. I can contribute it separately in whatever shape suits.
Not included
OoT skinned limbs, original F3D, T3D. Vertices are also still duplicated across vertex buffer boundaries, and the triangulation is whatever Blender produced, so a model may need its diagonals flipped by hand to reach the optimum.