diff --git a/apps/game/public/assets/PROVENANCE.md b/apps/game/public/assets/PROVENANCE.md index a39c38f..f103aa4 100644 --- a/apps/game/public/assets/PROVENANCE.md +++ b/apps/game/public/assets/PROVENANCE.md @@ -98,3 +98,36 @@ Kept: `Idle_Gun`, `Idle_Gun_Shoot`, `Walk`, `Run`, `Run_Shoot`, `Death`, These carry their own rig, materials and animations, so they do not follow the material-slot or `COL_` conventions the generated props use; `apps/game/src/assets.test.ts` exempts them explicitly. + +## Synty POLYGON Military + +| File | Source | Licence | +| --- | --- | --- | +| `models/fighter_insurgent.glb` | Synty POLYGON Military — `SK_Chr_Insurgent_Male_01` | Synty Store licence (purchased) | +| `models/fighter_soldier.glb` | Synty POLYGON Military — `SK_Chr_Soldier_Male_01` | Synty Store licence (purchased) | +| `textures/synty_atlas.webp` | Synty POLYGON Military — `PolygonMilitary_Texture_01_A` | Synty Store licence (purchased) | + +Purchased from https://syntystore.com. The licence grants perpetual, +royalty-free commercial use in unlimited titles and permits modification; it +does **not** permit reselling the assets as assets. Not copyright ownership, +which only a work-for-hire commission gives. + +Source files are **not** committed — the pack is 406 MB unpacked and lives +outside the repo. Only the converted GLBs and the one shared atlas ship. + +Converted with `tools/art/blender/import_synty.py`, which does two things the +plain glTF path cannot: + +- **Retargets animation.** Synty's SourceFiles carry a bind pose and nothing + else; the walk and run cycles are in the Unity and Unreal packages, which are + engine-locked. The script maps our CC0 clip set onto Synty's Unreal-standard + 55-bone skeleton with copy-rotation constraints and bakes the result. Copying + f-curves directly does not work — curves live in each bone's rest space and + the two rigs do not share rest orientations, so the result is a figure with + its arms through its chest. +- **Shares the atlas.** Synty puts every model in the pack on one 4096 texture. + Embedding it per character cost 2.48 MB each; binding it once by material + name costs 357 KB total and takes each character to ~650 KB. + +Kept clips: `Idle_Gun`, `Idle_Gun_Shoot`, `Walk`, `Run`, `Run_Shoot`, `Death`, +`HitRecieve`. diff --git a/apps/game/public/assets/manifest.json b/apps/game/public/assets/manifest.json index 05a56b9..b1007ce 100644 --- a/apps/game/public/assets/manifest.json +++ b/apps/game/public/assets/manifest.json @@ -1,7 +1,7 @@ { "generator": "tools/art/build-assets.mjs", "blender": "Blender 4.5.12 LTS", - "commit": "e132eb8caf8583e204fe8a2292219ebce39c6955", + "commit": "af4fbdcb5b822384fb6f8c3f84e98141465453aa", "license": "Original work, © NIGHTCELL 7. No third-party assets.", "source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.", "textureSize": 1024, @@ -11,8 +11,8 @@ "character.glb", "container.glb", "deck.glb", - "fighter_adventurer.glb", - "fighter_swat.glb", + "fighter_insurgent.glb", + "fighter_soldier.glb", "hardpoint.glb", "lamp_mast.glb", "pipe_rack.glb", @@ -42,7 +42,8 @@ "rust_orm.webp", "steel_albedo.webp", "steel_normal.webp", - "steel_orm.webp" + "steel_orm.webp", + "synty_atlas.webp" ], "audio": [ "ambience_yard.mp3", @@ -77,9 +78,9 @@ "throngva/the-wolf-called-want.mp3" ], "bytes": { - "models": 4206012, - "textures": 1971598, + "models": 2724980, + "textures": 2328964, "audio": 323166, - "total": 6500776 + "total": 5377110 } } diff --git a/apps/game/public/assets/models/fighter_adventurer.glb b/apps/game/public/assets/models/fighter_adventurer.glb deleted file mode 100644 index e281be1..0000000 Binary files a/apps/game/public/assets/models/fighter_adventurer.glb and /dev/null differ diff --git a/apps/game/public/assets/models/fighter_insurgent.glb b/apps/game/public/assets/models/fighter_insurgent.glb new file mode 100644 index 0000000..1221419 Binary files /dev/null and b/apps/game/public/assets/models/fighter_insurgent.glb differ diff --git a/apps/game/public/assets/models/fighter_soldier.glb b/apps/game/public/assets/models/fighter_soldier.glb new file mode 100644 index 0000000..4b7a468 Binary files /dev/null and b/apps/game/public/assets/models/fighter_soldier.glb differ diff --git a/apps/game/public/assets/models/fighter_swat.glb b/apps/game/public/assets/models/fighter_swat.glb deleted file mode 100644 index 771d823..0000000 Binary files a/apps/game/public/assets/models/fighter_swat.glb and /dev/null differ diff --git a/apps/game/public/assets/textures/synty_atlas.webp b/apps/game/public/assets/textures/synty_atlas.webp new file mode 100644 index 0000000..4a8917d Binary files /dev/null and b/apps/game/public/assets/textures/synty_atlas.webp differ diff --git a/apps/game/src/assets.test.ts b/apps/game/src/assets.test.ts index 946ba56..a52271e 100644 --- a/apps/game/src/assets.test.ts +++ b/apps/game/src/assets.test.ts @@ -78,7 +78,7 @@ describe("generated models", () => { * apply. Their provenance is a licence recorded in PROVENANCE.md rather than * a generator script. */ - const LICENSED = new Set(["fighter_swat", "fighter_adventurer"]); + const LICENSED = new Set(["fighter_insurgent", "fighter_soldier"]); it("only uses material slots the engine can bind", () => { // A slot the engine does not know about is not an error at load time: the diff --git a/apps/game/src/assets.ts b/apps/game/src/assets.ts index 5498cf1..59a9a75 100644 --- a/apps/game/src/assets.ts +++ b/apps/game/src/assets.ts @@ -58,8 +58,8 @@ export const MODELS = [ // Licensed CC0 characters (Quaternius). These carry their own rig, // animations and materials, so they bypass the material-slot convention the // generated props follow — see apps/game/public/assets/PROVENANCE.md. - "fighter_swat", - "fighter_adventurer", + "fighter_insurgent", + "fighter_soldier", ] as const; export type ModelName = (typeof MODELS)[number]; @@ -131,6 +131,23 @@ export function createMaterials(scene: Scene): Map(); - - const meshes = root.getChildMeshes() as Mesh[]; - for (const mesh of meshes) { - const source = mesh.material; - if (!source) continue; - let clone = seen.get(source); - if (!clone) { - clone = source.clone(`scene_${source.name}`) ?? source; - - // Kill emissive without gating on the material class. The yard runs a - // GlowLayer for the sodium lamps and it blooms *any* emissive surface; - // if the imported material is not the class we expect, an - // `instanceof` guard skips this silently and the character renders as a - // glowing white blob with a halo. - const anyMaterial = clone as unknown as { emissiveColor?: Color3; emissiveTexture?: unknown }; - if (anyMaterial.emissiveColor) anyMaterial.emissiveColor = new Color3(0, 0, 0); - anyMaterial.emissiveTexture = null; - - if (clone instanceof PBRMaterial) { - // 0.18, not 0.42. - // - // Worked back from the actual budget rather than guessed: the yard's - // hemispheric light is intensity 4.05 with a diffuse colour averaging - // ~0.45, so ~1.8 effective. At exposure 2.05, staying under the 0.62 - // bloom threshold needs an albedo near 0.17 — which is also where the - // yard's own concrete and steel sit. The pack authors its materials - // around 0.8 for a neutrally lit scene, so they need scaling by - // roughly a fifth, not a half. - clone.albedoColor = clone.albedoColor.scale(0.18); - clone.environmentIntensity = 0.3; - clone.enableSpecularAntiAliasing = true; - } - seen.set(source, clone); - } - mesh.material = clone; - - // Belt and braces: even with emissive cleared, keep characters out of the - // glow layer entirely so a future material change cannot reintroduce this. - for (const layer of scene.effectLayers) { - const glow = layer as unknown as { addExcludedMesh?: (m: Mesh) => void }; - glow.addExcludedMesh?.(mesh); - } - } -} - -/** - * Live opponents for the single-player sandbox. - * - * These are not scripted dummies. They run the real `MatchSimulation` from - * `@nightcell7/multiplayer-sim` with the real `BotController`, which produces - * `InputFrame`s and hands them to the same movement, fire-cadence, ammunition - * and hitscan code the authoritative server runs. A bot here cannot do anything - * a client could not, and its behaviour is the behaviour you will meet in a - * match — that is the whole reason to reuse the simulation rather than write - * "sandbox AI" that would immediately drift from it. - * - * The simulation is **local and presentational**. Nothing it decides is - * reported anywhere, nothing is scored, and in a real match the server owns all - * of it. The one deliberate cheat is the local player: rather than replaying - * their input through the sim and getting a second, diverging body, their real - * camera position is written straight into their sim player each tick. The - * bots therefore chase where the player actually is. - */ - /** Enemies on the Directorate side, and friendlies on the player's. */ const ENEMY_COUNT = 4; const FRIENDLY_COUNT = 3; @@ -135,7 +50,7 @@ export class Opponents { private readonly shots: BotShot[] = []; private readonly deadUntil = new Map(); - constructor(scene: Scene, assets: AssetSet) { + constructor(_scene: Scene, assets: AssetSet) { // One model per faction. // // Nightcell are irregulars: olive drab, boots, a pack — someone fighting @@ -154,9 +69,9 @@ export class Opponents { // // The generated character renders correctly, so the bots use it until the // licensed ones are diagnosed. Swapping back is a one-line change: - // assets.models.get("fighter_swat") / ("fighter_worker") - const enemyModel = assets.models.get("fighter_swat") ?? assets.models.get("character"); - const friendlyModel = assets.models.get("fighter_adventurer") ?? assets.models.get("character"); + // assets.models.get("fighter_soldier") / ("fighter_worker") + const enemyModel = assets.models.get("fighter_soldier") ?? assets.models.get("character"); + const friendlyModel = assets.models.get("fighter_insurgent") ?? assets.models.get("character"); const character = enemyModel; const carbine = assets.models.get("carbine"); if (!character) throw new Error("no character model loaded"); @@ -221,8 +136,6 @@ export class Opponents { // not attached on top of it. void carbine; - tameForScene(placed.root, scene); - this.views.set(id, { id, root: placed.root, diff --git a/tools/art/blender/import_synty.py b/tools/art/blender/import_synty.py new file mode 100644 index 0000000..236c22b --- /dev/null +++ b/tools/art/blender/import_synty.py @@ -0,0 +1,238 @@ +""" +Import a Synty POLYGON character and retarget our animation set onto it. + +Synty ships its SourceFiles without animation — the FBX carries a bind pose and +nothing else, because the walk and run cycles live in the Unity and Unreal +packages, which are engine-locked and unusable here. The characters are exactly +what the game needs (an `Insurgent` and a `Soldier`, by name, at ~2k triangles +on a 55-bone Unreal-standard skeleton) but they would stand frozen. + +The animation we already ship is CC0 and rigged to a different skeleton, so +this maps one onto the other. Both are ordinary humanoids and every limb has a +counterpart, which is what makes a plain bone-name map sufficient — no IK +solving, no motion re-synthesis. + +The retarget is done with copy-rotation constraints and a bake rather than by +copying f-curves directly. Curves are expressed in each bone's own rest space, +and the two rigs do not share rest orientations, so copying them produces a +figure with its arms through its chest. Constraints resolve in world space and +the bake writes the result back as ordinary keyframes. + +Usage: + blender --background --factory-startup --python import_synty.py -- \ + --fbx --anims --atlas \ + --out +""" + +from __future__ import annotations + +import os +import sys + +import bpy + +# Our animation rig on the left, the Unreal-standard Synty rig on the right. +# Fingers, toes, eyes and the IK helper bones are deliberately absent: nothing +# in our clip set animates them, and constraining them only adds bake cost. +BONE_MAP = { + "Hips": "Pelvis", + "Abdomen": "spine_01", + "Torso": "spine_02", + "Chest": "spine_03", + "Neck": "neck_01", + "Head": "head", + "Shoulder.L": "clavicle_l", + "UpperArm.L": "UpperArm_L", + "LowerArm.L": "lowerarm_l", + "Wrist.L": "Hand_L", + "Shoulder.R": "clavicle_r", + "UpperArm.R": "UpperArm_R", + "LowerArm.R": "lowerarm_r", + "Wrist.R": "Hand_R", + "UpperLeg.L": "Thigh_L", + "LowerLeg.L": "calf_l", + "Foot.L": "Foot_L", + "UpperLeg.R": "Thigh_R", + "LowerLeg.R": "calf_r", + "Foot.R": "Foot_R", +} + + +def arg(name: str, fallback=None): + return sys.argv[sys.argv.index(name) + 1] if name in sys.argv else fallback + + +def only_armature(objects): + found = [o for o in objects if o.type == "ARMATURE"] + if not found: + raise SystemExit("import_synty: no armature found") + return found[0] + + +def main() -> None: + fbx, anims, out = arg("--fbx"), arg("--anims"), arg("--out") + atlas = arg("--atlas") + if not fbx or not anims or not out: + raise SystemExit("import_synty: --fbx, --anims and --out are required") + + bpy.ops.wm.read_factory_settings(use_empty=True) + + # ---- the target: Synty character ------------------------------------- + bpy.ops.import_scene.fbx(filepath=fbx) + target = only_armature(bpy.data.objects) + target.name = "target_rig" + target_meshes = [o for o in bpy.data.objects if o.type == "MESH"] + + # Synty's bind pose is authored in centimetres; glTF is metres. + if abs(target.scale.x - 1.0) < 1e-6 and target.dimensions.z > 10: + target.scale = (0.01, 0.01, 0.01) + + # The FBX ships a bind-pose action that would otherwise export as a clip. + for action in list(bpy.data.actions): + bpy.data.actions.remove(action) + + # ---- the source: our animated rig ------------------------------------ + before = set(bpy.data.objects) + bpy.ops.import_scene.gltf(filepath=anims) + imported = [o for o in bpy.data.objects if o not in before] + source = only_armature(imported) + source.name = "source_rig" + # Only its animation is wanted; its geometry is discarded after baking. + source_meshes = [o for o in imported if o.type == "MESH"] + + clips = [a for a in bpy.data.actions if a.users > 0 or a.use_fake_user] + if not clips: + raise SystemExit("import_synty: source glb carried no actions") + + # ---- constrain target bones to their source counterparts ------------- + for our_name, synty_name in BONE_MAP.items(): + bone = target.pose.bones.get(synty_name) + if bone is None or source.pose.bones.get(our_name) is None: + continue + constraint = bone.constraints.new("COPY_ROTATION") + constraint.target = source + constraint.subtarget = our_name + constraint.target_space = "WORLD" + constraint.owner_space = "WORLD" + + # The hips also translate — without this the figure runs on the spot. + hips = target.pose.bones.get("Pelvis") + if hips and source.pose.bones.get("Hips"): + loc = hips.constraints.new("COPY_LOCATION") + loc.target = source + loc.subtarget = "Hips" + loc.target_space = "WORLD" + loc.owner_space = "WORLD" + + # ---- bake each clip onto the target ---------------------------------- + bones = [b for b in BONE_MAP.values() if target.pose.bones.get(b)] + baked = [] + + for clip in clips: + source.animation_data_create() + source.animation_data.action = clip + + start = int(clip.frame_range[0]) + end = int(clip.frame_range[1]) + bpy.context.scene.frame_start = start + bpy.context.scene.frame_end = end + + bpy.ops.object.select_all(action="DESELECT") + target.select_set(True) + bpy.context.view_layer.objects.active = target + bpy.ops.object.mode_set(mode="POSE") + for bone in target.pose.bones: + bone.bone.select = bone.name in bones + + bpy.ops.nla.bake( + frame_start=start, + frame_end=end, + only_selected=True, + visual_keying=True, + clear_constraints=False, + clear_parents=False, + use_current_action=False, + bake_types={"POSE"}, + ) + bpy.ops.object.mode_set(mode="OBJECT") + + action = target.animation_data.action + # Free the source name first. Blender appends .001 if the name is taken, + # and the engine looks clips up by exact name — "Walk.001" is a clip the + # game will never ask for. + source_name = clip.name + clip.name = f"__src_{source_name}" + action.name = source_name + action.use_fake_user = True + baked.append(action.name) + + # Constraints have served their purpose; leaving them in would re-drive the + # baked keys from a source rig that is about to be deleted. + for bone in target.pose.bones: + for constraint in list(bone.constraints): + bone.constraints.remove(constraint) + + for obj in [source, *source_meshes]: + bpy.data.objects.remove(obj, do_unlink=True) + + # Drop the source clips. They were renamed out of the way so the baked + # results could take their names, but they still carry fake users and would + # otherwise export alongside — doubling the animation count and the file. + for action in list(bpy.data.actions): + if action.name.startswith("__src_"): + bpy.data.actions.remove(action) + + # ---- material naming -------------------------------------------------- + # Rename to a slot the engine knows, so the shared atlas is bound at load + # exactly like the generated props' materials. Synty's own name + # ("Characters_For_Andrew:lambert") is an artist's working name and means + # nothing to us. + for mesh in target_meshes: + for slot in mesh.material_slots: + if slot.material: + slot.material.name = "synty_atlas" + + # ---- texture: Synty ships one shared atlas --------------------------- + if atlas and os.path.exists(atlas): + image = bpy.data.images.load(atlas) + for mesh in target_meshes: + for slot in mesh.material_slots: + material = slot.material + if not material: + continue + material.name = "synty_atlas" + material.use_nodes = True + bsdf = material.node_tree.nodes.get("Principled BSDF") + if not bsdf: + continue + tex = material.node_tree.nodes.new("ShaderNodeTexImage") + tex.image = image + material.node_tree.links.new(bsdf.inputs["Base Color"], tex.outputs["Color"]) + bsdf.inputs["Metallic"].default_value = 0.0 + bsdf.inputs["Roughness"].default_value = 0.85 + + os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) + bpy.ops.export_scene.gltf( + filepath=out, + export_format="GLB", + export_apply=False, + export_yup=True, + export_skins=True, + export_animations=True, + export_animation_mode="ACTIONS", + export_bake_animation=True, + export_cameras=False, + export_lights=False, + export_extras=False, + # No embedded images. Synty puts every character on one shared atlas, + # so embedding would ship the same 2 MB texture inside each character. + # The engine binds it once by material name, exactly as it does for the + # generated props. + export_image_format="NONE", + ) + + print(f"SYNTY {os.path.basename(out)} bytes={os.path.getsize(out)} clips={len(baked)}") + print(f" {', '.join(sorted(baked))}") + + +main()