Skip to content

Load mesh assets using temporary asset bundles#411

Open
Phantomical wants to merge 14 commits into
KSPModdingLibs:masterfrom
Phantomical:faster-model-loading
Open

Load mesh assets using temporary asset bundles#411
Phantomical wants to merge 14 commits into
KSPModdingLibs:masterfrom
Phantomical:faster-model-loading

Conversation

@Phantomical

Copy link
Copy Markdown
Collaborator

With #369 we now load dds and cached png textures by creating an in-memory asset bundle that points unity to the files on disk. We can then load this asset bundle async and unity will do all the work of reading the files and uploading them to the GPU for us. We can't quite do the same for mu files and the mesh assets within them. The mu files aren't in the right format, and even if they were KSP needs the mesh assets to be readable which means we can't put the mesh data in the streaming part of the asset bundle anyways.

What we can do, however, is parse all the mu files on background threads and build an asset bundle + a set of instructions that describe how to build the model game object. All the main thread needs to do then is wait for the meshes to be loaded from the asset bundle and then execute the instructions. The actual model loading becomes slower and more allocation-heavy, but that's okay because there are multiple background threads available and it can use all of them. To prevent the asset bundles from getting too large we group up model assets into batches of 512 and bake each group into its own bundle.

On its own this isn't really worth it, but building our own mesh asset bundles allows us to set the UV distribution metrics, which we'll need to support mipmap streaming.

Hand-writes Unity 2019.4 Mesh (class 43) objects into a UnityFS asset
bundle off the main thread, mirroring the texture bundle path. This is
the serialization foundation for moving .mu model loading off the
critical path.

Library/Model/:
- MeshBlob: mesh data container (channels, interleaved vertex bytes,
  index buffer, submeshes, bounds, bind pose).
- MeshBlobBuilder: interleaves attribute arrays into Unity's
  single-stream vertex layout and packs the index buffer (16/32-bit).
- MeshBundleBuilder: byte-exact WriteMeshBody + BuildMany, reusing the
  Library/TextureBundle writer stack. Canonicalizes m_Container keys
  (lowercase + forward slashes) so per-name LoadAssetAsync resolves,
  rejects duplicate keys, and sorts the container to match real bundles.
- MeshBundleSelfTest: #if DEBUG in-KSP round-trip test (also a
  canonicalization regression guard).

The class-43 Mesh type entry is merged into the shared
TextureBundle/TextureTypeTrees.bin (now holds 28, 43, 142) since the
writer resolves entries through one static ReferenceTypeTrees;
SerializedTypeTrees.MeshClassId added.

Validated byte-exact offline (single/multi-mesh, multi-submesh,
16/32-bit indices, absent channels) and in-KSP (readable round-trip,
per-name lookup, float32 color, rendering).
Extract MuParser's low-level binary primitives (Advance/ReadInt/ReadFloat/
ReadString/7-bit length/ReadVector*/ReadColor*/ReadBoneWeight/ReadMatrix4x4/
ReadKeyFrame + bulk Fill* memcpy helpers) into an instance-state unsafe struct
so many worker threads can parse different .mu files in parallel. Byte layout,
cursor advancement, bounds checks and UTF8 string decoding reproduce MuParser
exactly; the only shared state is an immutable UTF8Encoding.

Hardened over a straight extraction: Fill* helpers pass the destination's true
byte capacity to Buffer.MemoryCopy so an under-sized array throws instead of
corrupting the heap; Advance rejects negative/overflowing byte counts from
corrupt files. Documented the mutable-struct value-semantics contract
(hold in one field/local, pass by ref, never copy - as Utf8JsonReader).

Groundwork for MuModelCompiler (task KSPModdingLibs#4); MuParser stays as the oracle.
Split MuParser's GameObject assembly into main-thread replay instructions so a
background compiler (next task) can bake the data off-thread. Each
IModelInstruction.Execute reproduces the matching MuParser reader's object-
building tail exactly - verified field-by-field against the oracle:

- Hierarchy/tag+layer, mesh filter/renderer, skinned renderer + bone resolve
- Materials (shader resolved at replay incl. MuParser's null-shader fallback;
  last-wins singular sharedMaterial; scale/offset always applied; missing
  texture skipped + logged as MuParser does), collider set (mesh/sphere/
  capsule/box + isTrigger variants, wheel), light, camera, particle emitter
  (full KSPParticleEmitter field set + render-mode switch), legacy animation
  (isInvalid poisoning + curve-type mapping)

CompiledModel carries Instructions[]/MeshBlob[]/MeshBinding[]/LocalCount plus a
skinned-mesh flag (v1 falls back to MuParser for those). MeshBinding maps a
locals[] slot to a canonical bundle mesh name for LoadAssetAsync.

Data-carrier fields are baked by the not-yet-written compiler, so CS0649
"never assigned" warnings are expected and intentionally not suppressed.

Groundwork for MuModelCompiler (task KSPModdingLibs#4); MuParser stays as the oracle.
Port MuParser's opcode-tree walk into a background-safe compiler that emits a
flat IModelInstruction[] + MeshBlob[] + MeshBinding[] (a CompiledModel) instead
of building UnityEngine objects on the main thread. All state is per-instance
(reset per Compile, reusable per worker thread) with no statics; the buffer is
pinned for the whole walk and read via the single mutable MuBinaryReader field.

Faithful to MuParser for every real stock part (verified opcode-by-opcode
against the oracle and empirically over the corpus):
- Same EntryType dispatch + exact per-opcode read order/count; mesh sub-blocks
  (verts/uv/uv2/normals/tangents/triangles/colors/boneweights/bindposes) filled
  in order; slot policy (root=0; slots for GOs/meshes/materials/renderers/
  material-bound emitters; LocalCount = high-water).
- Two-pass material/texture resolution (Materials precedes Textures): texture
  slot refs resolved to urls at the Textures block (url = dir + "/" + name,
  isNormalMap from the texture entry, texCount guard preserved, fan-out to all
  referencing props); CreateMaterial+AssignMaterial emitted in ascending
  material-index order to preserve last-wins singular sharedMaterial.
- v<4 ShaderType property-name table + v>=4 by-name shader, version gating
  (shadow flags v>=1, spot angle v>1), ResolveBones emitted last.
- Skinned meshes (SkinnedMeshRenderer / bone weights / bind poses) fully
  consume their bytes to keep the cursor valid and flag ContainsSkinnedMesh;
  the v1 pipeline falls back to MuParser.Parse for those files.
- Never throws: any parse error returns Failed=true with SourceUrl set.

Offline-validated over 3506 real .mu (0 throws, 0 Failed, 0 sanity violations,
195 skinned flagged) with 40/40 sampled real-mesh bundles byte-exact incl. a
100k-vert 32-bit-index mesh, 7-submesh, and 544-mesh models. Completes the
compiler half of the feature; FastLoader pipeline wiring is the next task.
MuParser stays as the skinned fallback (deleted in a later task).
MuModelCompiler runs on background PLINQ worker threads, but it was calling
Debug.Log*/LogWarning/LogError directly (skinned notice, mesh error, texture-
count guard, and MeshBlobBuilder's attribute-length warnings). Unity's own
logger is synchronized, but KSP installs its own ILogHandler and mods chain
handlers onto Application.logMessageReceived, and those are NOT thread-safe -
logging from the compile workers can corrupt their state or crash.

Fix: the compiler buffers diagnostics into CompiledModel.Logs (a
List<DeferredLog> of type+message) during compilation and never logs itself;
CompiledModel.FlushLogs() emits them safely on the main thread and will be
called by the replay pipeline. MeshBlobBuilder.FromArrays/FromMesh take an
optional Action<string> warn sink (default null -> Debug.LogWarning for
main-thread/offline callers like MeshBundleSelfTest and the harness); the
compiler passes a cached sink that buffers into its log list. Kept
MeshBlobBuilder free of any CompiledModel dependency so the offline MeshHarness
still builds unchanged. MeshBundleBuilder only throws (no logging), so it needed
no change. ModelInstructions' logging stays as-is (it runs on the main thread at
replay).
Replace the main-thread FilesLoader/RawAsset/MuParser model path with a
grouped background pipeline mirroring the texture bundle loader:

- CompileModelGroups (background Task): PLINQ AsParallel().AsOrdered() compiles
  every .mu via a per-thread MuModelCompiler, chunks the ordered stream into
  count-based ~512-model groups, and MeshBundleBuilder.BuildMany's one bundle
  per group into a bounded BlockingCollection. .dae, skinned, compile-failed
  entries pass through as ordered markers.
- ModelBundlePumpCoroutine (main thread): LoadFromMemoryAsync per group as bytes
  arrive, drops the managed byte[], and forwards each group's requests in order.
- ModelDriverCoroutine/LoadModelCoroutine/InsertReadyModel trio (mirrors the
  texture driver): one coroutine per model, per-mesh LoadAssetAsync (not
  LoadAllAssetsAsync) into locals[], replay the IModelInstruction[] on the main
  thread, then FIFO in-order registration with first-wins dedup - preserving
  databaseModel order, modelsByDirectoryUrl first-wins, and byte-identical
  registration vs the old RawAsset path.

Load order walks modelAssets exactly as before (AsOrdered fold -> in-order span
-> ordered pump -> FIFO drain that waits on a Pending head, never reorders).

Robustness (untestable offline, so hardened defensively):
- Size-based pump backpressure (MaxResidentModelBundleBytes) bounds resident
  native bundle memory; bounded groupQueue bounds managed pressure - replacing
  the old 50MB streaming cap.
- Per-group isolation: a BuildMany or LoadFromMemoryAsync failure hard-fails
  just that group's models (clear logged error) instead of aborting the fold.
- All compiler diagnostics flush on the main thread (FlushLogs); the only
  remaining off-thread fault is stored and logged main-thread by the driver.
- InsertReadyModel accounting (PendingBundleRefs decrement + Unload + resident
  release) runs exactly-once in a finally on every path; the driver logs+skips a
  throwing model instead of dying.

No MuParser fallback for compile/replay failures - those hard-fail with a logged
error so compiler bugs surface (and get fixed) rather than being masked, since
MuParser is deleted in a later task. The only MuParser use is the temporary
skinned-mesh fallback, removed once the compiler gains skinned support.

Dead machinery (FilesLoader/ReadAssetsThread/RawAsset model methods/arrayPool/
MuParser) is kept for now and removed in the cleanup task.
…ata)

Extend the hand-written Unity 2019.4.18f1 Mesh (class 43) serializer to emit
skinned meshes, reverse-engineered from 542 real skinned meshes in KSP's
sharedassets0.assets and byte-structurally validated:

- Vertex channels 12 (BlendWeights, format 0/Float32, dim 4) and 13
  (BlendIndices, format 10/UInt32, dim 4) appended to the single interleaved
  stream 0; weights written as float32, bone indices as raw uint32.
- m_BindPose (Matrix4x4[], serializedFloat[R*4+C]).
- m_BoneNameHashes / m_RootBoneNameHash / m_BonesAABB populated to maintain
  Unity's bindpose==boneNameHashes==bonesAABB count invariant
  (m_VariableBoneCountWeights stays empty). BonesAABB filled conservatively with
  the whole-mesh AABB per bone.
- Bone name hash = zlib CRC32 over the UTF-8 full transform path from the model
  root; verified exact against all 35 bones of a reference mesh plus a
  single-bone mesh.

MeshBlobBuilder.Arrays gains BoneWeights/BindPoses/BoneNames; AddChannel takes a
format param so ch13 emits UInt32. Static meshes are unchanged: ch12/13 absent,
all skin arrays empty, byte-identical to before (verified against golden
bundles; all static self-consistency + semantic validators still pass).

Single-stream/all-dim4 layout differs from Unity's native multi-stream layout
but honors each channel's (stream,offset,format,dim) descriptor - the same
approach already proven for static meshes; whether Unity skins from it needs the
in-KSP check when the compiler wiring makes this path live.

Serializer capability only - currently dormant (the compiler still discards skin
data and routes skinned models to the temporary MuParser fallback; wiring is the
next unit).
…lag)

Make MuModelCompiler populate skin data instead of discarding it, so skinned
models load via the background bundle pipeline rather than the temporary
MuParser fallback:

- ReadMesh stores MeshBoneWeights -> Arrays.BoneWeights and MeshBindPoses ->
  Arrays.BindPoses (same read order/cursor as before).
- The SkinnedMeshRenderer block's bone-name list is threaded into the mesh's
  Arrays.BoneNames (currentBoneNames field, set before ReadMesh, cleared after),
  index-aligned with the bind poses. The same bone-name list feeds both the
  mesh's cosmetic BoneNameHashes and the ResolveBones binding, so
  bindpose[i] <-> BlendIndices==i <-> bones[i] <-> BoneNameHashes[i] all match.
- ContainsSkinnedMesh is never set now (MarkSkinned removed), so skinned models
  classify as CompiledMu and flow through the bundle path; the pipeline's
  Kind.Skinned fallback branch is dead (removed with MuParser later).
- ReconcileBoneNames pads the cosmetic hash-source list to the bind-pose count
  for the rare .mu where an SMR declares fewer bone names than the mesh has bind
  poses (e.g. stock TriBitDrill, restock-claw), preserving Unity's per-bone count
  invariant while ResolveBones still binds from the original list - byte-for-byte
  MuParser-equivalent. Removed the now-resolved CS0649 pragma on the Arrays skin
  fields.

Offline-validated over the real corpus: 0 failures, ContainsSkinnedMesh==0, 164
models now serialize skinned meshes (269 skinned blobs) with 0 structural
violations; sampled real skinned bundles are byte-structurally valid
(ch12=fmt0/dim4, ch13=fmt10/dim4, bind-pose/hash/AABB invariant); static output
unchanged. Whether Unity deforms from this single-stream layout is the in-KSP
gate (task KSPModdingLibs#7).
Each ModelGroup's AssetBundleCreateRequest is shared by all its model
coroutines, and the driver runs up to MaxModelSpawnsPerFrame of them at once.
Yielding one AsyncOperation from multiple coroutines is forbidden by Unity
('This asynchronous operation is already being yielded from another
coroutine') - it produced 33 errors on the first in-KSP load. Poll
CreateRequest.isDone with 'yield return null' instead of yielding the shared
op; each coroutine waits independently. Per-mesh LoadAssetAsync results are
unique per coroutine and still yielded directly. Verified in-KSP: 0 async
errors (was 33), 1573 models load, MeshSelfTest PASS, main menu reached.
Main-menu #if DEBUG addon that, for a sample of real .mu (default 60 spread +
all skinned/robotic/complex parts forced in), builds each model both via
MuParser.Parse (oracle) and via the new Compile->BuildMany->LoadAssetAsync->
replay path, then diffs hierarchy (names/TRS/component types), mesh data
(verts/normals/tangents/uv/uv2/colors/triangles/bounds), and skin
(bindposes/boneWeights/bones). Logs [ModelDiff] PASS/FAIL + SUMMARY.

Verified in-KSP: 183/183 passed (60 sampled + 123 complex) - the bundle path
reproduces MuParser exactly, including skinned meshes (boneWeights/bindposes
read back identical, proving channels 12/13 deserialize correctly). This is the
semantic-parity gate; the harness (and MuParser) are removed in the cleanup
task.
The background bundle pipeline is validated in-KSP (183/183 diff-parity vs
MuParser, incl. skinned parts; clean full-database load), so the old path and
the deprecated Library.MuParser are removed - completing the full migration:

- Delete Library/MuParser.cs (the old .mu parser) and the DEBUG ModelDiffHarness
  (its oracle).
- FastLoader: remove FilesLoader/ReadAssetsThread, strip RawAsset to a thin
  carrier (kept AssetType enum + File/ctor used by the texture path and the
  model bucketing), drop arrayPool/loadedBytes/lockObject and the
  MuParser.ReleaseBuffers cleanup.
- Remove the now-dead skinned->MuParser fallback: the Kind.Skinned branch/arm,
  LoadSkinnedModelCoroutine, ModelLoadRequest.RawBytes/RawLength, and
  CompiledModel.ContainsSkinnedMesh (the compiler serializes skinned meshes
  directly, so it never flagged them).

.dae is already loaded inline by LoadDaeModelCoroutine. QoL.MuParser (an
unrelated texture-name scanner in NoIVA.cs) and MeshBundleSelfTest are untouched.
~2113 lines removed; Debug and Release both build clean; in-KSP relaunch loads
all 1573 models with 0 errors.
MeshMetrics.Compute now bakes the real per-UV-channel m_MeshMetrics value
during mesh compilation (was a 1.0 placeholder), wired through
MeshBlobBuilder and verified by MeshBundleSelfTest, so the serialized mesh
matches Unity's m_MeshMetrics.

Split from 16a0cc7 on streaming-mipmaps: this is the mesh-serialization
half of that commit. The mipmap-streaming consumer (the release-at-bind
logic plus the texture/FastLoader machinery) stays on streaming-mipmaps.
@Phantomical
Phantomical force-pushed the faster-model-loading branch from 367e9b3 to 4db8007 Compare July 16, 2026 09:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant