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()