Volver al índice
docs/render-corridor-demo.py
"""Show native crawl3 bracing inside a visible one-by-three stone corridor.
Only the environment and camera belong to this script. Geometry, animation and
skin come from the existing Java exporter and preview renderer, unchanged.
"""
from __future__ import annotations
import argparse
import hashlib
import importlib.util
import json
import time
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
# INPUTS: import the established renderer without editing any shared asset.
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "docs"
CLASSES = ROOT / "build/parasite-preview-classes"
SCRIPT = Path(__file__).resolve()
RENDERER = OUT / "render-parasite-preview.py"
spec = importlib.util.spec_from_file_location("corridor_preview", RENDERER)
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
FPS, DURATION, SIZE = 20, 12, (480, 440)
STEM = "parasito-crawl3-paredes"
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
# MATERIAL: deterministic, original stone pixels; the host atlas stays intact.
def install_stone_material():
original = p.texture_pixels
textures = {}
rng = np.random.default_rng(134)
for name in ("parasite.png", "parasite_initial.png", "parasite_friendly.png"):
atlas = np.zeros((256, 128, 4), dtype=np.uint8)
atlas[:128] = original(name)
noise = rng.integers(-10, 11, (128, 128))
gray = 91 + noise
yy, xx = np.indices(gray.shape)
gray[(xx % 16 == 0) | (yy % 16 == 0)] = 42
gray[(xx % 16 == 1) | (yy % 16 == 1)] = 112
atlas[128:, :, :3] = np.stack((gray, gray + 1, gray + 3), axis=-1)
atlas[128:, :, 3] = 255
textures[name] = atlas
# In-memory adapter only: the original module and atlas files are untouched.
p.texture_pixels = lambda name: textures[name]
# ENVIRONMENT: inner walls are x=-8/+8 units; three blocks from floor to roof.
def corridor_sample(sample, seconds):
shift = (seconds * 12) % 16
boxes = []
for row in range(-3, 5):
z = row * 16 + shift
for level in range(3):
boxes.append({"bounds": [-24, -24 + level * 16, z, 16, 16, 16], "uv": [0, 128]})
for column in range(3):
boxes.append({"bounds": [-24 + column * 16, 24, z, 16, 16, 16], "uv": [0, 128]})
bone = {"name": "demo_stone", "parent": None, "pivot": [0, 0, 0], "boxes": boxes}
result = dict(sample)
result["bones"] = [*sample["bones"], bone]
result["pose"] = dict(sample["pose"])
result["pose"][bone["name"]] = {"position": [0, 0, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1], "visible": True}
return result
# CAMERA: a fixed cutaway makes both hands and their actual wall planes visible.
FRAME = (p.camera_matrix(12, 10), 6.7, np.array([SIZE[0] / 2, 220]))
def project(points):
cam, scale, offset = FRAME
return [(float(x), float(y)) for x, y in ((cam @ np.asarray(points).T).T[:, :2] * scale + offset)]
def frame(sample, seconds):
image = Image.new("RGBA", SIZE, (13, 15, 19, 255))
image.alpha_composite(p.render(corridor_sample(sample, seconds), SIZE, FRAME))
# Near wall is deliberately a transparent cutaway, never a hidden collision.
layer = Image.new("RGBA", SIZE)
draw = ImageDraw.Draw(layer)
draw.polygon(project([(8, -24, -32), (8, 24, -32), (8, 24, 64), (8, -24, 64)]), fill=(133, 143, 149, 23))
shift = (seconds * 12) % 16
for y in (-24, -8, 8, 24):
draw.line(project([(8, y, -32), (8, y, 64)]), fill=(164, 172, 178, 88), width=1)
for row in range(-3, 5):
z = row * 16 + shift
draw.line(project([(8, -24, z), (8, 24, z)]), fill=(164, 172, 178, 88), width=1)
# The roof edge states the passage height while leaving the face visible.
draw.line(project([(-8, -24, -32), (8, -24, -32)]), fill=(150, 150, 154, 190), width=2)
image.alpha_composite(layer)
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((17, 16, 226, 47), radius=6, fill=(12, 14, 18, 235))
draw.text((28, 20), "PASO 1×3 · APOYO LATERAL", font=p.font(14, True), fill=(216, 205, 195))
return image.convert("RGB")
# CONTACT: measure real palm/finger vertices against their respective walls.
def wall_contact(samples):
gaps = {"left_wall": [], "right_wall": []}
heights = {"left_wall": [], "right_wall": []}
for sample in samples:
for side, wall, label in (("r", -8, "left_wall"), ("l", 8, "right_wall")):
names = {bone["name"] for bone in sample["bones"]
if bone["name"].startswith("arm_" + side + "_")
and any(part in bone["name"] for part in ("hand", "finger", "thumb"))}
vertices = np.concatenate([vertices for box in p.mesh(sample, names) for vertices, _ in p.cube_faces(box)])
closest = vertices[np.argmin(abs(vertices[:, 0] - wall))]
gaps[label].append(float(abs(closest[0] - wall) / 16))
heights[label].append(float((24 - closest[1]) / 16))
result = {label: {"minimum_gap_blocks": min(values), "maximum_gap_blocks": max(values),
"contact_height_range_blocks": [min(heights[label]), max(heights[label])]} for label, values in gaps.items()}
# Do not visually claim bracing if the actual fingers are visibly detached.
if max(max(values) for values in gaps.values()) > .025:
raise ValueError("Native crawl3 hands are too far from the side walls: " + json.dumps(result))
return result
# EXPORT: contact sheet first; the twelve-second video leaves existing clips intact.
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--contact-only", action="store_true")
args = parser.parse_args()
if CLASSES.joinpath("ar/com/companeros/horror/client/ParasiteRig.class").stat().st_mtime < p.RIG.stat().st_mtime:
raise RuntimeError("Compile the current native rig before rendering this demonstration")
sources = {str(path.relative_to(ROOT)).replace("\\", "/"): sha(path) for path in
(p.RIG, p.MODEL, RENDERER, SCRIPT, p.ASSETS / "parasite.png")}
samples = p.rig_sequence(CLASSES, "crawl3", 4, DURATION)
contacts = wall_contact(samples)
install_stone_material()
selected = [0, 13, 26, 39, 52, 65]
sheet = Image.new("RGB", (SIZE[0] * 3, SIZE[1] * 2), (13, 15, 19))
for index, number in enumerate(selected):
sheet.paste(frame(samples[number], number / FPS), ((index % 3) * SIZE[0], (index // 3) * SIZE[1]))
sheet.save(OUT / (STEM + "-contact.png"))
frame(samples[26], 26 / FPS).save(OUT / (STEM + ".png"))
print("Contact sheet ready; measured wall contacts: " + json.dumps(contacts), flush=True)
if args.contact_only:
return
started = time.monotonic()
def frames():
for index, sample in enumerate(samples):
yield frame(sample, index / FPS)
if (index + 1) % 20 == 0:
print(f"Rendered {index + 1}/{len(samples)} frames ({time.monotonic() - started:.1f}s)", flush=True)
p.write_video(frames(), OUT / (STEM + ".mp4"))
for relative, expected in sources.items():
if sha(ROOT / relative) != expected:
raise RuntimeError("Source changed during the demonstration: " + relative)
metadata = {"mode": "crawl3", "stage": 4, "fps": FPS, "duration_seconds": DURATION,
"frame_count": len(samples), "size": list(SIZE), "source_hashes": sources,
"native_samples_sha256": p.sample_digest(samples), "wall_contacts": contacts,
"corridor_inner_width_blocks": 1, "corridor_height_blocks": 3,
"camera": {"yaw_degrees": 12, "pitch_degrees": 10, "near_wall": "transparent_cutaway"},
"environment_motion_blocks_per_second": .75,
"output_hashes": {name: sha(OUT / name) for name in
(STEM + ".mp4", STEM + ".png", STEM + "-contact.png")}}
(OUT / (STEM + "-origen.json")).write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
print("Demonstration complete", flush=True)
if __name__ == "__main__":
main()