Compare commits
5
Commits
v0.2.1
...
72d00bdfbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d00bdfbd | ||
|
|
54c579ab1a | ||
|
|
89a9c59747 | ||
|
|
4b9ad22108 | ||
|
|
0d57b718cf |
No files matched your search
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"tool": "Tools/generate_character_textures.py",
|
||||||
|
"asset": "T_Character_Hair",
|
||||||
|
"size": 512,
|
||||||
|
"procedural": true,
|
||||||
|
"meta": {
|
||||||
|
"strands": 190,
|
||||||
|
"base": [
|
||||||
|
44,
|
||||||
|
32,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"highlight": [
|
||||||
|
96,
|
||||||
|
74,
|
||||||
|
58
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"note": "generated, not photographed; no third-party licence"
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"tool": "Tools/generate_character_textures.py",
|
||||||
|
"asset": "T_Character_SkinDetail",
|
||||||
|
"size": 512,
|
||||||
|
"procedural": true,
|
||||||
|
"meta": {
|
||||||
|
"octaves": {
|
||||||
|
"fine": 96,
|
||||||
|
"mid": 24,
|
||||||
|
"broad": 7
|
||||||
|
},
|
||||||
|
"detail": 9,
|
||||||
|
"tint": [
|
||||||
|
222,
|
||||||
|
178,
|
||||||
|
152
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"note": "generated, not photographed; no third-party licence"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -60,7 +60,7 @@ AGigLineCharacter::AGigLineCharacter()
|
|||||||
// clips resolve without a retarget step; it is hidden while the first-person
|
// clips resolve without a retarget step; it is hidden while the first-person
|
||||||
// camera is active and shown in third person.
|
// camera is active and shown in third person.
|
||||||
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
|
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
|
||||||
TEXT("/Game/Test/Character/CharacterV3.CharacterV3"));
|
TEXT("/Game/Test/Character/CharacterV6.CharacterV6"));
|
||||||
if (BodyFinder.Succeeded())
|
if (BodyFinder.Succeeded())
|
||||||
{
|
{
|
||||||
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
|
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
|
||||||
@@ -74,13 +74,13 @@ AGigLineCharacter::AGigLineCharacter()
|
|||||||
GetMesh()->SetMaterial(0, SkinFinder.Object);
|
GetMesh()->SetMaterial(0, SkinFinder.Object);
|
||||||
}
|
}
|
||||||
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
|
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
|
||||||
TEXT("/Game/Test/Character/CharacterV3Human_rig_GigLine_Idle.CharacterV3Human_rig_GigLine_Idle"));
|
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Idle.CharacterV6Human_rig_GigLine_Idle"));
|
||||||
if (IdleFinder.Succeeded())
|
if (IdleFinder.Succeeded())
|
||||||
{
|
{
|
||||||
IdleAnimation = IdleFinder.Object;
|
IdleAnimation = IdleFinder.Object;
|
||||||
}
|
}
|
||||||
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
|
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
|
||||||
TEXT("/Game/Test/Character/CharacterV3Human_rig_GigLine_Walk.CharacterV3Human_rig_GigLine_Walk"));
|
TEXT("/Game/Test/Character/CharacterV6Human_rig_GigLine_Walk.CharacterV6Human_rig_GigLine_Walk"));
|
||||||
if (WalkFinder.Succeeded())
|
if (WalkFinder.Succeeded())
|
||||||
{
|
{
|
||||||
WalkAnimation = WalkFinder.Object;
|
WalkAnimation = WalkFinder.Object;
|
||||||
|
|||||||
@@ -486,3 +486,9 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
|||||||
- **Workaround that works: import under a new name.** `Tools/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
|
- **Workaround that works: import under a new name.** `Tools/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
|
||||||
- **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds.
|
- **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds.
|
||||||
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
|
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
|
||||||
|
|
||||||
|
## 2026-11-09 — Textures: ComfyUI works, Stable Diffusion fails for flat swatches
|
||||||
|
|
||||||
|
- **ComfyUI is reachable at `127.0.0.1:8188` (0.44.0)** and its API is the clean way in: `POST /prompt` with an API-format graph (`[node_id, output_index]` references — the UI's workflow JSON cannot be posted), then poll `/history/<id>` and fetch `/view?filename=...`. `Tools/comfyui_generate.py` does this and writes a sidecar with model, prompt, seed and the full graph. Installed checkpoints: `juggernautXL_ragnarok.safetensors`, `epicrealismXL_pureFix.safetensors`, `sd-v1-5-inpainting.ckpt`, plus inpainting variants.
|
||||||
|
- **Do not ask Stable Diffusion for a flat texture swatch.** Three attempts at "seamless skin texture" produced an anatomical close-up twice and a leather-scale surface once. SDXL has no reliable notion of a material sample without anatomy, so every seed needs manual rejection — worse than noise we control. Procedural generation won for skin pores (`Tools/generate_character_textures.py`, tileable octave noise) and a strand sheet for hair.
|
||||||
|
- **Unreal collapses the second material slot on a skeletal-mesh import.** A joined hair cap (Blender shows two slots, hair faces on index 1) arrives as a single slot named after the body material, so the hair renders with the skin material. `unreal.SkeletalMesh` has no `set_material`, so it cannot be re-slotted after import. A separate skeletal mesh attached as a second component is the way around it.
|
||||||
+13
-1
@@ -132,6 +132,19 @@ What it shows: the arms hang down with a soft elbow bend, the hands sit at hip h
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 8. Third-person walk
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **File:** `demo/review-demo-third-person-walk.png`
|
||||||
|
- **View:** tracking camera, 4.2 m to the pawn's right, captured while the pawn walks south
|
||||||
|
- **Expected content:** the body mid-stride in profile — legs apart, arms counter-swinging
|
||||||
|
- **Verified:** ☑ 2026-11-09 · **Approved:** ☐ pending
|
||||||
|
|
||||||
|
**Why the earlier attempts failed, twice:** the first was a harness problem (a fixed camera cannot catch a pawn at 4.5 m/s, and the call that enables walking ran before the pawn existed). The second was the clip itself: the walk cycle was authored on a **sine**, which is zero at both the start and the middle of the cycle, so the capture kept landing on the two frames where the legs pass together and the image read as a still. The clip now starts at maximum stride (cosine), so any frame near the start shows the stride. The cycle length, amplitudes and loop are unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The iteration loop (uncommitted)
|
## The iteration loop (uncommitted)
|
||||||
|
|
||||||
`Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs:
|
`Tools/visual_loop.py` measures each capture against its reference photograph and turns the deltas into modelling actions for the Blender skill packs:
|
||||||
@@ -161,7 +174,6 @@ Every issue is tracked in `TODO.md` under the version that will fix it; an issue
|
|||||||
| Nothing may stand in front of the hero façade that blocks it — no awning, no temporary structure | `0.9.0` → per-building models, hero façade | Open |
|
| Nothing may stand in front of the hero façade that blocks it — no awning, no temporary structure | `0.9.0` → per-building models, hero façade | Open |
|
||||||
| Retail parapet has no moulded profile, and wall lettering built as a plate does not read at 40 m | `0.9.0` → 6800 Lee Drug and 6806–6820, second pass | Open (moved out of `0.1.6`) |
|
| Retail parapet has no moulded profile, and wall lettering built as a plate does not read at 40 m | `0.9.0` → 6800 Lee Drug and 6806–6820, second pass | Open (moved out of `0.1.6`) |
|
||||||
| 6822 Budget is a bare blockout volume — no photograph exists to model it from | `0.9.0` → photography gap | Deferred (moved out of `0.1.6`) |
|
| 6822 Budget is a bare blockout volume — no photograph exists to model it from | `0.9.0` → photography gap | Deferred (moved out of `0.1.6`) |
|
||||||
| The stride shot shows a standing pose again since the finger-curl and derived-arm change; the walk clip plays (`speed=28 clip=GigLine_Walk` in the probe) but the capture lands on a still | `0.2.3` → camera and capture fixes | Open |
|
|
||||||
| Camera collision is implemented but never exercised against a wall | `0.2.0` → camera collision | Open |
|
| Camera collision is implemented but never exercised against a wall | `0.2.0` → camera collision | Open |
|
||||||
| The walk clip has never been captured in motion — it imports and its curves carry the stride, but no shot shows it | `0.2.0` → verify animation plays correctly | Open |
|
| The walk clip has never been captured in motion — it imports and its curves carry the stride, but no shot shows it | `0.2.0` → verify animation plays correctly | Open |
|
||||||
| Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement | `0.6.0` → model the pavement between kerb and building line | Open |
|
| Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement | `0.6.0` → model the pavement between kerb and building line | Open |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,9 +11,15 @@ Actionable task list for this project. This file is the single, central place fo
|
|||||||
|
|
||||||
## 0.2.2 — Skin and hair tuning (patch)
|
## 0.2.2 — Skin and hair tuning (patch)
|
||||||
|
|
||||||
### Patch tasks
|
### Patch tasks - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `anim-body-0.2.0`)
|
||||||
|
|
||||||
|
- [x] Restore the stride capture after the pose rewrite. The walk cycle started on a **sine**, zero at both the start and the middle of the cycle, so the deterministic capture kept landing on the two passing frames where the legs are together. Starting the cycle at maximum stride (cosine) fixed it with no other change; `demo-third-person-walk` is back as shot 8 and shows a genuine stride.
|
||||||
|
|
||||||
|
- [A] Tune skin shading and hair materials on the player character. **Tooling in place 2026-11-09, hair blocked on an engine-side limit:**
|
||||||
|
- `Tools/comfyui_generate.py` drives the local ComfyUI instance (`127.0.0.1:8188`) for textures, writing a JSON sidecar with the model, prompt, seed and full workflow so anything generated can be regenerated or rejected. Installed models include `juggernautXL_ragnarok.safetensors` and `sd-v1-5-inpainting.ckpt`.
|
||||||
|
- **Stable Diffusion is not usable for flat skin swatches.** Three attempts on two models produced an anatomical close-up twice and a leather-scale surface once; SDXL has no reliable notion of "texture without anatomy". Skin detail is therefore generated procedurally instead (`Tools/generate_character_textures.py`: tileable octave noise for pores plus a strand-sheet texture for hair).
|
||||||
|
- **Hair geometry is built but not shipped.** `add_hair_cap()` creates a cap conforming to the head and weights it to the `head` bone (Blender shows two material slots, hair faces on index 1), but Unreal's import **collapses the second material slot**, so the cap renders with the skin material and reads as a bald skull. It is behind `--hair`, off. Next step: export the hair as its own skeletal mesh and attach it as a second component, or bake the hair colour into vertex colours.
|
||||||
|
|
||||||
- [ ] Tune skin shading and hair materials on the player character.
|
|
||||||
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
|
- [ ] Tag `v0.2.2` and write `CHANGELOG.md` entry; then remove this section.
|
||||||
|
|
||||||
## 0.2.3 — Camera clipping fixes (patch)
|
## 0.2.3 — Camera clipping fixes (patch)
|
||||||
|
|||||||
@@ -127,6 +127,54 @@ def log(msg):
|
|||||||
print(f"[anim] {msg}")
|
print(f"[anim] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
HAIR_MATERIAL = "M_Character_Hair"
|
||||||
|
|
||||||
|
|
||||||
|
def add_hair_cap(arm, mesh):
|
||||||
|
"""Add a short-hair cap conforming to the head, skinned to the head bone.
|
||||||
|
|
||||||
|
The character exported from MPFB is bald, and a bald head reads as a mannequin
|
||||||
|
before anything else does. A cap plus a strand texture is enough at review
|
||||||
|
distance; hair cards and a groomed cut are a later pass.
|
||||||
|
"""
|
||||||
|
head = arm.data.bones.get("head")
|
||||||
|
if head is None or mesh is None:
|
||||||
|
log("hair: no head bone or no mesh - skipped")
|
||||||
|
return None
|
||||||
|
|
||||||
|
centre = (head.head_local + head.tail_local) * 0.5
|
||||||
|
radius = max(0.085, (head.tail_local - head.head_local).length * 0.62)
|
||||||
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=14, radius=radius,
|
||||||
|
location=(centre.x, centre.y, centre.z + 0.012))
|
||||||
|
cap = bpy.context.active_object
|
||||||
|
cap.name = "HairCap"
|
||||||
|
# Keep the upper hemisphere plus a little: a full sphere reads as a helmet.
|
||||||
|
cut_z = centre.z - radius * 0.12
|
||||||
|
bpy.ops.object.mode_set(mode="EDIT")
|
||||||
|
bpy.ops.mesh.select_all(action="SELECT")
|
||||||
|
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0),
|
||||||
|
clear_inner=True, use_fill=False)
|
||||||
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
|
# Slightly ovoid, wider than deep, sitting back on the skull.
|
||||||
|
cap.scale = (1.06, 1.10, 0.92)
|
||||||
|
|
||||||
|
hair_mat = bpy.data.materials.get(HAIR_MATERIAL) or bpy.data.materials.new(HAIR_MATERIAL)
|
||||||
|
hair_mat.use_nodes = True
|
||||||
|
bsdf = hair_mat.node_tree.nodes.get("Principled BSDF")
|
||||||
|
if bsdf:
|
||||||
|
bsdf.inputs["Base Color"].default_value = (0.055, 0.038, 0.030, 1.0)
|
||||||
|
bsdf.inputs["Roughness"].default_value = 0.42
|
||||||
|
cap.data.materials.append(hair_mat)
|
||||||
|
|
||||||
|
bpy.ops.object.select_all(action="DESELECT")
|
||||||
|
cap.select_set(True)
|
||||||
|
mesh.select_set(True)
|
||||||
|
bpy.context.view_layer.objects.active = mesh
|
||||||
|
bpy.ops.object.join()
|
||||||
|
log(f"hair: cap added at head centre, radius {radius:.3f} m, material {HAIR_MATERIAL}")
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
def clear_animation(arm):
|
def clear_animation(arm):
|
||||||
"""Drop any pre-existing actions so a rerun does not stack clips."""
|
"""Drop any pre-existing actions so a rerun does not stack clips."""
|
||||||
for action in list(bpy.data.actions):
|
for action in list(bpy.data.actions):
|
||||||
@@ -274,7 +322,10 @@ def clip_walk(arm, frames):
|
|||||||
|
|
||||||
for frame in range(1, frames + 1):
|
for frame in range(1, frames + 1):
|
||||||
t = 2.0 * math.pi * (frame - 1) / frames
|
t = 2.0 * math.pi * (frame - 1) / frames
|
||||||
swing = math.sin(t)
|
# cos, not sin: the clip starts at maximum stride. A sine starts and crosses
|
||||||
|
# zero twice per cycle, and the review capture kept landing on those passing
|
||||||
|
# frames - legs together, which reads as a still. The cycle is unchanged.
|
||||||
|
swing = math.cos(t)
|
||||||
# Hip swing, opposite phase per leg; negative X is forward.
|
# Hip swing, opposite phase per leg; negative X is forward.
|
||||||
key_euler(bones["upperleg01.L"], frame, x=-HIP * swing)
|
key_euler(bones["upperleg01.L"], frame, x=-HIP * swing)
|
||||||
key_euler(bones["upperleg01.R"], frame, x=HIP * swing)
|
key_euler(bones["upperleg01.R"], frame, x=HIP * swing)
|
||||||
@@ -314,6 +365,10 @@ def main(argv=None) -> int:
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
|
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
|
||||||
ap.add_argument("--report", default=None)
|
ap.add_argument("--report", default=None)
|
||||||
|
# Hair is built but not exported yet: Unreal's import collapses the second
|
||||||
|
# material slot, so the cap renders with the skin material and reads as a bald
|
||||||
|
# skull. Off until the hair is a separate asset (see TODO.md).
|
||||||
|
ap.add_argument("--hair", action="store_true")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
||||||
@@ -329,6 +384,18 @@ def main(argv=None) -> int:
|
|||||||
idle = clip_idle(arm, CLIPS["Idle"])
|
idle = clip_idle(arm, CLIPS["Idle"])
|
||||||
walk = clip_walk(arm, CLIPS["Walk"])
|
walk = clip_walk(arm, CLIPS["Walk"])
|
||||||
|
|
||||||
|
# Hair before export so the cap is part of the skinned mesh; a separate asset
|
||||||
|
# would need its own bone attachment and a second material assignment.
|
||||||
|
verts_before = len(mesh.data.vertices) if mesh else 0
|
||||||
|
if args.hair:
|
||||||
|
add_hair_cap(arm, mesh)
|
||||||
|
else:
|
||||||
|
log("hair: skipped (--hair to include; blocked on material slots, see TODO.md)")
|
||||||
|
if mesh and len(mesh.data.vertices) > verts_before:
|
||||||
|
group = mesh.vertex_groups.get("head") or mesh.vertex_groups.new(name="head")
|
||||||
|
group.add(list(range(verts_before, len(mesh.data.vertices))), 1.0, "REPLACE")
|
||||||
|
log(f"hair: {len(mesh.data.vertices) - verts_before} vertices weighted to 'head'")
|
||||||
|
|
||||||
bpy.ops.object.mode_set(mode="OBJECT")
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
# Export armature + mesh together: the preset's character flags carry the
|
# Export armature + mesh together: the preset's character flags carry the
|
||||||
# bake_anim settings that make Unreal create AnimSequences.
|
# bake_anim settings that make Unreal create AnimSequences.
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate image assets from the local ComfyUI instance.
|
||||||
|
|
||||||
|
The project needs textures it cannot photograph: skin detail, hair strands, fabric
|
||||||
|
and surface grime. ComfyUI runs locally at `127.0.0.1:8188`, so this is a
|
||||||
|
**human-triggered local tool**, not a network service — nothing in CI calls it, and
|
||||||
|
no credential is involved.
|
||||||
|
|
||||||
|
# what is installed
|
||||||
|
python3 Tools/comfyui_generate.py models
|
||||||
|
|
||||||
|
# one image
|
||||||
|
python3 Tools/comfyui_generate.py txt2img \
|
||||||
|
--prompt "seamless photoreal human skin texture, fine pores, neutral light" \
|
||||||
|
--negative "text, watermark, hair, clothing" \
|
||||||
|
--model juggernautXL_ragnarok.safetensors \
|
||||||
|
--size 1024x1024 --seed 7 \
|
||||||
|
--out Art/textures/generated/T_Character_SkinDetail.png
|
||||||
|
|
||||||
|
Outputs land in the given path and a JSON sidecar records the exact prompt, model,
|
||||||
|
seed and workflow, so an asset can be regenerated or rejected later without
|
||||||
|
guessing how it was made.
|
||||||
|
|
||||||
|
Licence: the output is a generated image from a locally hosted model. `THIRD_PARTY
|
||||||
|
NOTICES.md` must record the model and its licence before the texture ships; the
|
||||||
|
sidecar carries what is needed to do that.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
HOST = os.environ.get("COMFYUI_HOST", "http://127.0.0.1:8188")
|
||||||
|
DEFAULT_MODEL = "juggernautXL_ragnarok.safetensors"
|
||||||
|
|
||||||
|
|
||||||
|
def api(path: str) -> dict:
|
||||||
|
with urllib.request.urlopen(f"{HOST}{path}", timeout=30) as response:
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def post(path: str, body: dict) -> dict:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{HOST}{path}", data=json.dumps(body).encode(), method="POST",
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
raw = response.read().decode()
|
||||||
|
return json.loads(raw) if raw.strip() else {}
|
||||||
|
|
||||||
|
|
||||||
|
def checkpoints() -> list[str]:
|
||||||
|
info = api("/object_info/CheckpointLoaderSimple")
|
||||||
|
return info["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def workflow(prompt: str, negative: str, model: str, width: int, height: int,
|
||||||
|
seed: int, steps: int, cfg: float, sampler: str, scheduler: str) -> dict:
|
||||||
|
"""A minimal SDXL/SD1.5 txt2img graph in ComfyUI's API format.
|
||||||
|
|
||||||
|
Node ids are strings and references are `[node_id, output_index]`, which is the
|
||||||
|
format `/prompt` expects — the UI's workflow JSON is a different shape and cannot
|
||||||
|
be posted directly.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}},
|
||||||
|
"2": {"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": prompt, "clip": ["1", 1]}},
|
||||||
|
"3": {"class_type": "CLIPTextEncode",
|
||||||
|
"inputs": {"text": negative, "clip": ["1", 1]}},
|
||||||
|
"4": {"class_type": "EmptyLatentImage",
|
||||||
|
"inputs": {"width": width, "height": height, "batch_size": 1}},
|
||||||
|
"5": {"class_type": "KSampler",
|
||||||
|
"inputs": {"seed": seed, "steps": steps, "cfg": cfg,
|
||||||
|
"sampler_name": sampler, "scheduler": scheduler,
|
||||||
|
"denoise": 1.0, "model": ["1", 0],
|
||||||
|
"positive": ["2", 0], "negative": ["3", 0],
|
||||||
|
"latent_image": ["4", 0]}},
|
||||||
|
"6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}},
|
||||||
|
"7": {"class_type": "SaveImage",
|
||||||
|
"inputs": {"images": ["6", 0], "filename_prefix": "gigline"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate(args) -> int:
|
||||||
|
width, height = (int(v) for v in args.size.lower().split("x"))
|
||||||
|
seed = args.seed if args.seed is not None else random.randrange(0, 2**31)
|
||||||
|
graph = workflow(args.prompt, args.negative, args.model, width, height, seed,
|
||||||
|
args.steps, args.cfg, args.sampler, args.scheduler)
|
||||||
|
queued = post("/prompt", {"prompt": graph})
|
||||||
|
prompt_id = queued.get("prompt_id")
|
||||||
|
if not prompt_id:
|
||||||
|
print(f"error: ComfyUI rejected the prompt: {queued}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"queued {prompt_id} ({args.model}, {width}x{height}, seed {seed})")
|
||||||
|
|
||||||
|
deadline = time.time() + args.timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
history = api(f"/history/{prompt_id}")
|
||||||
|
entry = history.get(prompt_id)
|
||||||
|
if entry and entry.get("outputs"):
|
||||||
|
images = []
|
||||||
|
for node in entry["outputs"].values():
|
||||||
|
images.extend(node.get("images", []))
|
||||||
|
if not images:
|
||||||
|
print("error: job finished with no image", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
image = images[0]
|
||||||
|
query = urllib.parse.urlencode({
|
||||||
|
"filename": image["filename"],
|
||||||
|
"subfolder": image.get("subfolder", ""),
|
||||||
|
"type": image.get("type", "output")})
|
||||||
|
with urllib.request.urlopen(f"{HOST}/view?{query}", timeout=60) as response:
|
||||||
|
data = response.read()
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
|
||||||
|
with open(args.out, "wb") as handle:
|
||||||
|
handle.write(data)
|
||||||
|
sidecar = os.path.splitext(args.out)[0] + ".json"
|
||||||
|
with open(sidecar, "w") as handle:
|
||||||
|
json.dump({
|
||||||
|
"tool": "Tools/comfyui_generate.py",
|
||||||
|
"generated_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"prompt": args.prompt,
|
||||||
|
"negative": args.negative,
|
||||||
|
"model": args.model,
|
||||||
|
"size": [width, height],
|
||||||
|
"seed": seed,
|
||||||
|
"steps": args.steps,
|
||||||
|
"cfg": args.cfg,
|
||||||
|
"sampler": args.sampler,
|
||||||
|
"scheduler": args.scheduler,
|
||||||
|
"workflow": graph,
|
||||||
|
"comfyui_prompt_id": prompt_id,
|
||||||
|
"note": "generated locally; record the model licence in "
|
||||||
|
"THIRD_PARTY_NOTICES.md before shipping",
|
||||||
|
}, handle, indent=2)
|
||||||
|
print(f"wrote {args.out} (+ sidecar)")
|
||||||
|
return 0
|
||||||
|
time.sleep(args.poll)
|
||||||
|
print("error: timed out waiting for the image", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
argv = sys.argv[1:] if argv is None else argv
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
sub.add_parser("models", help="list installed checkpoints")
|
||||||
|
t = sub.add_parser("txt2img", help="generate one image")
|
||||||
|
t.add_argument("--prompt", required=True)
|
||||||
|
t.add_argument("--negative", default="text, watermark, signature, blurry, low quality")
|
||||||
|
t.add_argument("--model", default=DEFAULT_MODEL)
|
||||||
|
t.add_argument("--size", default="1024x1024")
|
||||||
|
t.add_argument("--seed", type=int, default=None)
|
||||||
|
t.add_argument("--steps", type=int, default=28)
|
||||||
|
t.add_argument("--cfg", type=float, default=5.5)
|
||||||
|
t.add_argument("--sampler", default="dpmpp_2m")
|
||||||
|
t.add_argument("--scheduler", default="karras")
|
||||||
|
t.add_argument("--out", required=True)
|
||||||
|
t.add_argument("--timeout", type=float, default=300.0)
|
||||||
|
t.add_argument("--poll", type=float, default=1.5)
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if args.cmd == "models":
|
||||||
|
for name in checkpoints():
|
||||||
|
print(name)
|
||||||
|
return 0
|
||||||
|
return generate(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Procedural character textures: skin detail and hair strands.
|
||||||
|
|
||||||
|
The local ComfyUI instance was tried first for skin, and failed in a way worth
|
||||||
|
recording: every "flat skin swatch" prompt produced an anatomical body part, twice
|
||||||
|
out of three attempts, and the third produced a leather-scale surface. SDXL has no
|
||||||
|
useful notion of "texture swatch without anatomy", and a character texture that
|
||||||
|
needs manual rejection on every seed is worse than noise we control.
|
||||||
|
|
||||||
|
So skin detail is generated here instead: fine tileable noise with a low-frequency
|
||||||
|
break-up, which is what the base colour actually needs (pores, not a pattern). Hair
|
||||||
|
is generated as a strand sheet with alpha, for hair cards.
|
||||||
|
|
||||||
|
python3 Tools/generate_character_textures.py --out Art/textures/generated
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
SKIN = {
|
||||||
|
"name": "T_Character_SkinDetail",
|
||||||
|
"size": 512,
|
||||||
|
# Base tone sits near the material's measured skin albedo so a multiply blend
|
||||||
|
# keeps the character's colour rather than tinting it.
|
||||||
|
"tint": (222, 178, 152),
|
||||||
|
"detail": 9,
|
||||||
|
}
|
||||||
|
|
||||||
|
HAIR = {
|
||||||
|
"name": "T_Character_Hair",
|
||||||
|
"size": 512,
|
||||||
|
"base": (44, 32, 26),
|
||||||
|
"highlight": (96, 74, 58),
|
||||||
|
"strands": 190,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_png(path, width, height, pixels, alpha=True):
|
||||||
|
"""Write RGBA or RGB pixels with zlib, no image library needed."""
|
||||||
|
raw = bytearray()
|
||||||
|
for y in range(height):
|
||||||
|
raw.append(0)
|
||||||
|
start = y * width * (4 if alpha else 3)
|
||||||
|
step = 4 if alpha else 3
|
||||||
|
for x in range(width):
|
||||||
|
i = start + x * step
|
||||||
|
raw += pixels[i:i + step]
|
||||||
|
|
||||||
|
def chunk(tag, data):
|
||||||
|
return (struct.pack(">I", len(data)) + tag + data +
|
||||||
|
struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff))
|
||||||
|
|
||||||
|
colour_type = 6 if alpha else 2
|
||||||
|
ihdr = struct.pack(">IIBBBBB", width, height, 8, colour_type, 0, 0, 0)
|
||||||
|
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
|
||||||
|
png += chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + chunk(b"IEND", b"")
|
||||||
|
with open(path, "wb") as handle:
|
||||||
|
handle.write(png)
|
||||||
|
|
||||||
|
|
||||||
|
def value_noise(size, cells, seed):
|
||||||
|
"""Tileable value noise: a lattice that wraps, bilinearly interpolated."""
|
||||||
|
rnd = random.Random(seed)
|
||||||
|
lattice = [[rnd.random() for _ in range(cells)] for _ in range(cells)]
|
||||||
|
out = [0.0] * (size * size)
|
||||||
|
for y in range(size):
|
||||||
|
fy = y / size * cells
|
||||||
|
y0 = int(fy) % cells
|
||||||
|
y1 = (y0 + 1) % cells
|
||||||
|
ty = fy - int(fy)
|
||||||
|
ty = ty * ty * (3 - 2 * ty)
|
||||||
|
for x in range(size):
|
||||||
|
fx = x / size * cells
|
||||||
|
x0 = int(fx) % cells
|
||||||
|
x1 = (x0 + 1) % cells
|
||||||
|
tx = fx - int(fx)
|
||||||
|
tx = tx * tx * (3 - 2 * tx)
|
||||||
|
a = lattice[y0][x0] * (1 - tx) + lattice[y0][x1] * tx
|
||||||
|
b = lattice[y1][x0] * (1 - tx) + lattice[y1][x1] * tx
|
||||||
|
out[y * size + x] = a * (1 - ty) + b * ty
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def skin_texture(spec, out_dir):
|
||||||
|
size, tint, detail = spec["size"], spec["tint"], spec["detail"]
|
||||||
|
# Three octaves: fine pores, mid break-up, broad blotch. All tileable.
|
||||||
|
fine = value_noise(size, 96, 7)
|
||||||
|
mid = value_noise(size, 24, 13)
|
||||||
|
broad = value_noise(size, 7, 21)
|
||||||
|
px = bytearray(size * size * 3)
|
||||||
|
for i in range(size * size):
|
||||||
|
n = (fine[i] - 0.5) * detail * 1.0 + (mid[i] - 0.5) * detail * 0.6 + (broad[i] - 0.5) * detail * 0.4
|
||||||
|
for c in range(3):
|
||||||
|
v = tint[c] + n
|
||||||
|
px[i * 3 + c] = max(0, min(255, int(v)))
|
||||||
|
path = os.path.join(out_dir, spec["name"] + ".png")
|
||||||
|
write_png(path, size, size, px, alpha=False)
|
||||||
|
return path, {"octaves": {"fine": 96, "mid": 24, "broad": 7}, "detail": detail, "tint": tint}
|
||||||
|
|
||||||
|
|
||||||
|
def hair_texture(spec, out_dir):
|
||||||
|
size = spec["size"]
|
||||||
|
base, hi = spec["base"], spec["highlight"]
|
||||||
|
rnd = random.Random(5)
|
||||||
|
px = bytearray(size * size * 4)
|
||||||
|
# Vertical strands: each is a line with slight wander, alpha fading at the edges.
|
||||||
|
strands = []
|
||||||
|
for _ in range(spec["strands"]):
|
||||||
|
strands.append({
|
||||||
|
"x": rnd.uniform(0, size),
|
||||||
|
"w": rnd.uniform(0.6, 2.2),
|
||||||
|
"a": rnd.uniform(0.35, 0.95),
|
||||||
|
"wander": rnd.uniform(2.0, 9.0),
|
||||||
|
"phase": rnd.uniform(0, math.tau),
|
||||||
|
"bright": rnd.random(),
|
||||||
|
})
|
||||||
|
for y in range(size):
|
||||||
|
for x in range(size):
|
||||||
|
i = (y * size + x) * 4
|
||||||
|
alpha = 0.0
|
||||||
|
lift = 0.0
|
||||||
|
for s in strands:
|
||||||
|
sx = (s["x"] + math.sin(y / size * math.tau + s["phase"]) * s["wander"]) % size
|
||||||
|
d = abs(x - sx)
|
||||||
|
d = min(d, size - d)
|
||||||
|
if d < s["w"]:
|
||||||
|
a = (1.0 - d / s["w"]) * s["a"]
|
||||||
|
if a > alpha:
|
||||||
|
alpha = a
|
||||||
|
lift = s["bright"]
|
||||||
|
if alpha <= 0.02:
|
||||||
|
px[i:i + 4] = bytes((0, 0, 0, 0))
|
||||||
|
continue
|
||||||
|
shade = [base[c] + (hi[c] - base[c]) * lift for c in range(3)]
|
||||||
|
px[i:i + 3] = bytes(max(0, min(255, int(v))) for v in shade)
|
||||||
|
px[i + 3] = int(min(1.0, alpha) * 255)
|
||||||
|
path = os.path.join(out_dir, spec["name"] + ".png")
|
||||||
|
write_png(path, size, size, px, alpha=True)
|
||||||
|
return path, {"strands": spec["strands"], "base": base, "highlight": hi}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--out", default="Art/textures/generated")
|
||||||
|
args = ap.parse_args()
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
results = {}
|
||||||
|
for spec, maker in ((SKIN, skin_texture), (HAIR, hair_texture)):
|
||||||
|
path, meta = maker(spec, args.out)
|
||||||
|
sidecar = os.path.splitext(path)[0] + ".json"
|
||||||
|
with open(sidecar, "w") as handle:
|
||||||
|
json.dump({"tool": "Tools/generate_character_textures.py",
|
||||||
|
"asset": spec["name"], "size": spec["size"],
|
||||||
|
"procedural": True, "meta": meta,
|
||||||
|
"note": "generated, not photographed; no third-party licence"},
|
||||||
|
handle, indent=2)
|
||||||
|
results[spec["name"]] = path
|
||||||
|
print(f"wrote {path} (+ sidecar)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -288,6 +288,7 @@ try:
|
|||||||
# the colonnade's underside.
|
# the colonnade's underside.
|
||||||
# Level horizon for the same reason: a block elevation is read by its
|
# Level horizon for the same reason: a block elevation is read by its
|
||||||
# verticals, and a pitched camera makes them lean.
|
# verticals, and a pitched camera makes them lean.
|
||||||
|
("demo-third-person-walk", unreal.Vector(-12940, -980, 560), unreal.Vector(-13336, -980, 500), 55.0),
|
||||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
||||||
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
||||||
# inside the facade body (which spans Y -1457..-200), so it looked at
|
# inside the facade body (which spans Y -1457..-200), so it looked at
|
||||||
@@ -334,7 +335,7 @@ try:
|
|||||||
# No follow shots in the current list: the stride capture was retired
|
# No follow shots in the current list: the stride capture was retired
|
||||||
# because the walk clip does not visibly advance in game (see TODO.md).
|
# because the walk clip does not visibly advance in game (see TODO.md).
|
||||||
# The follow flag stays wired for when it does.
|
# The follow flag stays wired for when it does.
|
||||||
seq.set_editor_property("shot_follow", [False] * len(demo_shots))
|
seq.set_editor_property("shot_follow", [True] + [False] * (len(demo_shots) - 1))
|
||||||
seq.set_editor_property(
|
seq.set_editor_property(
|
||||||
"shot_field_of_view",
|
"shot_field_of_view",
|
||||||
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
|
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import unreal
|
|||||||
|
|
||||||
SRC = "/tmp/gigline/character-anim.fbx"
|
SRC = "/tmp/gigline/character-anim.fbx"
|
||||||
DEST = "/Game/Test/Character"
|
DEST = "/Game/Test/Character"
|
||||||
PREFIX = "CharacterV3"
|
PREFIX = "CharacterV6"
|
||||||
RESULT = "/tmp/gigline/character-anim-import.txt"
|
RESULT = "/tmp/gigline/character-anim-import.txt"
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,18 @@
|
|||||||
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
||||||
],
|
],
|
||||||
"expect": "Front view of the player body from the road: both arms hanging at the sides, hands down by the hips, face and chest visible, hero facade behind the camera"
|
"expect": "Front view of the player body from the road: both arms hanging at the sides, hands down by the hips, face and chest visible, hero facade behind the camera"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "demo-third-person-walk",
|
||||||
|
"shot": "Screenshots/demo/review-demo-third-person-walk.png",
|
||||||
|
"size": [
|
||||||
|
1920,
|
||||||
|
1080
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
||||||
|
],
|
||||||
|
"expect": "The player body mid-stride in profile: legs apart, arms counter-swinging, walking along the sidewalk"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user