Importing from Unity
ponko_unity reads a Unity project and turns its scenes and prefabs into
Crabby scenes: every drawn mesh becomes a .glb with its
materials, its textures become PNGs, and the scene is a crabby-world
file of Transform3D and Model entities that load_scene loads. It
runs once, as a tool, and writes into your game’s assets/.
cargo run --release -p ponko_unity --example import -- \
MyUnityGame my_game/assets/town town/ Assets/Scenes/Town.unity
That writes my_game/assets/town/Town.json (load it with
ctx.load_scene("town/Town.json")), the meshes in town/meshes/ and their
textures in town/meshes/textures/, then says what it left out and which
shaders the materials used. Give it several scenes or prefabs at once and
they share their meshes and textures; ONE=all.json puts all their
entities in one scene.
To look around what came out:
SCENE=town/Town.json cargo run --release --features 3d --example scene_viewer
Unity’s +Z is Crabby’s -Z: everything is mirrored along Z on the way, so a scene looks the same, and positions and rotations come out in Crabby’s space.
What comes over
- Meshes from mesh assets (
.asset), FBX and OBJ files (scaled and oriented as Unity’s model importer does), and Unity’s built-in Cube, Plane and Quad. Each submesh keeps its material. A model imported with Bake Axis Conversion has its file’s axes (Z-up, say) turned into Unity’s in the mesh itself, as Unity does, so parts placed one by one in a prefab line up. - Materials of URP’s Lit, Simple Lit and Unlit shaders, and of other shaders that use the same property names: base color and map, normal map, metallic and smoothness (packed into glTF’s occlusion, roughness and metalness image), emission, tiling, cutout, transparency and two-sided, plus occlusion. A custom shader is drawn as plain PBR by the same rules; the report lists the shaders used, so you know which need a Crabby shader of their own.
- Textures from image files, at most as large as their import settings allow, and from texture assets saved uncompressed.
- Prefabs inside prefabs, with their overrides, baked into one flat list of entities placed in the world.
- Colliders, if you ask (
importer.colliders(true)): box, sphere, capsule and mesh colliders become entities with a fixedRigidBody3Dand aCollider3D(3D physics), the object’s scale worked into the shape. A mesh collider’s mesh is written as a.glband named in the scene by file;ctx.load_scenemakes a triangle mesh of it (a convex hull for a convex one), once per mesh and scale, and saving the scene names the file again. Triggers are left out.
Vertex colors are kept only for shaders you name (see below): URP’s shaders ignore them, so most meshes are drawn as Unity draws them without.
Renderers keep their Cast Shadows setting: Off gets a ShadowMesh of
none, and Shadows Only (often a simplified stand-in for the shadows of the
pieces around it) becomes a ShadowMesh with no Model, drawn only into
the shadow maps.
Levels of detail
A Unity LODGroup comes over as a LodGroup on one entity placed
at the group’s object. Every level keeps its renderers (each placed
relative to the group), its screen-relative height as the level’s
screen_height, and its fadeTransitionWidth as fade_width when the group’s
Fade Mode is on. The group’s m_Size is the size and its reference point
the center. A renderer’s Cast Shadows setting becomes the level’s shadow:
a level with a renderer set to Off or Shadows Only casts the shadow of
the right meshes, not the ones it shows.
#![allow(unused)]
fn main() {
import.lod_groups(false); // LOD 0 only, the far levels left out
}
Animated cross-fading (a fade over seconds) isn’t supported: those groups fade over a band of screen heights instead, and the report counts them. Skinned renderers can’t be part of a group: one in LOD 0 stays an entity of its own, and the ones in far levels are left out (also counted).
FBX and uncompressed Unity .asset skinned meshes export inverse bind
matrices and up to four influences per vertex (weights below 0.001 are
dropped, then the largest four are normalized). Mesh assets support legacy
bone weights and VertexData blend channels. Renderer bone references use
the flattened scene’s names, hierarchy and overridden rest transforms when
all joints resolve; otherwise the source skeleton is retained (mesh assets
use unnamed bind-pose joints). Left out, and counted in the report: skinned renderers in far levels of detail, inactive objects and disabled renderers, the built-in
Sphere, Capsule and Cylinder, compressed meshes, and meshes or textures
it can’t find or read.
Your own shaders for imported materials
Materials of the project’s own shaders come over as plain PBR. To draw them with a Crabby shader of your own, tell the importer which shaders those are:
#![allow(unused)]
fn main() {
import.custom_shader("Assets/Shaders/Wall.shader"); // as the report lists it
}
Their meshes then keep their vertex colors and UV set 1 (which custom
shaders often read; in Crabby’s shaders they’re s.color and s.uv1,
UV set 1 unflipped, as Unity has it), and the importer writes
materials.json next to the scenes: each such material by name, with its
shader and every number, color and texture it sets, the textures written
as PNGs. In the game, loaded_materials(name) gives every material loaded
from glTF files under that name, with what it was made from, so you can
keep that and change the shader:
#![allow(unused)]
fn main() {
// The house's walls, drawn with a shader of our own: keep what they
// were made from (textures, colors) and change the shader.
let weathered = ctx.create_shader(
"weathered.wgsl",
"fn surface(s: Surface) -> Surface { var o = s; o.albedo *= 0.8; o.roughness = 0.95; return o; }",
);
for (walls, desc) in ctx.loaded_materials("walls") {
ctx.update_material(
walls,
MaterialDesc {
shader: Some(weathered),
..desc
},
);
}
}
// The house's walls, drawn with a shader of our own: keep what they
// were made from (textures, colors) and change the shader.
auto weathered = ctx.create_shader("weathered.wgsl",
"fn surface(s: Surface) -> Surface { var o = s; o.albedo *= 0.8; o.roughness = 0.95; return o; }");
for (auto [walls, desc] : ctx.loaded_materials("walls")) {
desc.has_shader = true;
desc.shader = weathered;
ctx.update_material(walls, desc);
}
Call it after the scene (or model) is loaded: models loaded later aren’t
in it yet. C: crabby_ctx_loaded_materials.
A shader that reads something else per vertex, such as a second UV set, can have it put in the vertex colors instead, which is what Crabby’s shaders get per vertex besides one UV set:
#![allow(unused)]
fn main() {
// A river shader with its flow in UV set 1: speed up to 4, foam 0 to 1.
import.vertex_colors("Assets/Shaders/River.shader", |mesh, v| {
let [speed, foam] = mesh.uvs[1].get(v).copied().unwrap_or_default();
[speed / 4.0, foam, 0.0, 1.0]
});
}
Each channel keeps 8 bits, from 0 to 1, so scale values into that range and back in the shader.
Model animation
Skinned FBX models export their takes as glTF animations targeting the same nodes as the skin. Local translation, quaternion rotation and scale are sampled at the file’s frame rate, including clip endpoints and source key times. Constant tracks use STEP interpolation; other sampled tracks use LINEAR. The axis conversion, import scale and Z mirror match the rest pose.
The model’s .fbx.meta clipAnimations entries select a take (takeName)
and slice firstFrame through lastFrame into named clips. Each clip starts
at zero seconds. Without entries, every take exports under its take name.
Loop flags are preserved as animation extras.loop; the player chooses
whether to repeat playback. Renderer bone references preserve joint order,
and m_RootBone supplies the glTF skeleton root. Missing bone references
retain the unresolved model bones while applying the resolved scene bones.
Standalone .anim clips (text or binary serialized) also import:
#![allow(unused)]
fn main() {
let clips = [ponko_unity::AnimationClip::read("Assets/Walk.anim")?];
import.animation_clips(clips); // before importing scenes / exporting cached meshes
}
Clips target each exported skin’s root (empty path) and child names along
slash-separated Transform paths. Unresolved or ambiguous paths are reported.
AnimationClip::append_to_skin also attaches clips directly to a MeshSkin
in Unity space. The importer uses the same glTF writer and Z mirror as FBX.
Quaternion, position, scale and Euler curves use cubic Hermite sampling at
m_SampleRate, including keys and endpoints; Euler rotations use Unity’s
Z-X-Y order. Clip settings select start/stop times (output starts at zero)
and preserve the loop flag. Infinite tangents evaluate as steps; fully step
tracks use glTF STEP. Mixed step/smooth tracks use sampled LINEAR, so jumps
between samples are approximated. Weighted tangents are approximated as
unweighted Hermite and reported.
Which curves a clip uses
A clip saved by the editor usually carries two copies of its animation: the
curves you authored (m_PositionCurves, m_RotationCurves, m_ScaleCurves,
m_EulerCurves, the editor’s per-property m_EditorCurves such as
m_LocalPosition.x and m_EulerEditorCurves, and blend-shape float curves)
and the runtime data Unity bakes from them (m_MuscleClip with
m_ClipBindingConstant). When there are authored curves, the importer uses
them and ignores the baked copy. Properties that a per-property curve leaves
out keep their rest pose: a clip that only animates m_LocalPosition.x
leaves y and z alone. Where plain transform curves and per-property curves
describe the same target, the plain ones win.
A clip without authored curves is decoded from the baked data:
- Streamed, dense and constant curves: cubic keys per time,
samples at a fixed rate (linear between frames), and single values. Their
channels are bound to targets through the clip’s
genericBindings: the CRC-32 of a transform’s slash-separated path (below the node the clip is applied to) and an attribute (position, rotation, scale or Euler angles). Hash and node must match exactly; unmatched or ambiguous paths are reported. - Compressed rotation curves (
m_Compressed, bit-packed keys). - Blend shapes in baked data are only identified by a hash of their
name. The importer matches it against the blend shapes of the mesh being
exported (
AnimationClip::with_blend_shape_namesdoes the same for your own use); unmatched ones are reported.
Binary serialized clips (Unity’s Mixed or Binary asset serialization,
2022 or newer, saved by the editor with type trees) read into the same
fields, so they behave like their text twins. AnimationClip::read picks
the format; parse takes text and parse_binary bytes.
What does not import: humanoid clips whose curves are muscles and root motion need retargeting, so a clip with only those is an error, and one that mixes them with transform curves imports the transform curves and reports the rest. Float curves on anything but blend shapes, object reference curves, weighted tangents (approximated), animation events and Animator controllers are skipped and reported or not imported yet. The compressed-curve tangent layout and the baked decoders are checked against synthetic data built from the formats’ public descriptions, not against every Unity version’s output.
Terrain
Unity terrain (TerrainData, text or binary) reads as a
UnityTerrain: its heights in world units, already in Crabby’s row order,
ready for a terrain tile, and the layers painted on it with
their weight maps.
#![allow(unused)]
fn main() {
let file = ponko_unity::UnityFile::read("MyUnityGame/Assets/Terrain/Hills.asset")?;
let data = file.of_class(156).next().expect("a TerrainData");
let hills = ponko_unity::UnityTerrain::from_object(data)?;
// In the game (or written to a file the game reads):
let tile = ctx.create_terrain_tile(&hills.heights, hills.size, ground);
let [x, y, z] = hills.corner([0.0, 0.0, 0.0]); // the Unity Terrain's position
ctx.world.spawn((Transform3D::at(vec3(x, y, z)), tile));
}
The weight maps are Texture2Ds in the same file: read them with
UnityTexture and their rows already line up with the tile.
From C and C++
The importer is a Rust tool: run it once, as above. What it writes are
ordinary scene, glTF and PNG files, which C and C++ games load with
ctx.load_scene(path) (C: crabby_ctx_load_scene) like any other.