Load mesh assets using temporary asset bundles#411
Open
Phantomical wants to merge 14 commits into
Open
Conversation
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
force-pushed
the
faster-model-loading
branch
from
July 16, 2026 09:23
367e9b3 to
4db8007
Compare
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.
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.