mirror of
https://github.com/kamb205/KB-RPCS3.git
synced 2026-10-22 05:00:59 +02:00
869 lines
45 KiB
Python
869 lines
45 KiB
Python
"""PS5 Vulkan Template - the assets made here: meshes, textures, conversions.
|
|
|
|
The asset pack's files whose licence is unclear are replaced by ones made by
|
|
these functions, from geometry and noise written here or from CC0 sources
|
|
(Poly Haven, Khronos's glTF sample models) downloaded at pinned hashes.
|
|
build-assets.py calls build(name, out_dir, download) for each "generated"
|
|
entry of ps5/assets.json. Everything is deterministic: the same inputs give
|
|
the same bytes, so the deploy tool sees unchanged files as unchanged.
|
|
|
|
The sizes follow what each sample expects of the file it replaces (its
|
|
camera, its instance scales, its texture format); ps5/ASSETS.md lists them.
|
|
|
|
Copyright (C) 2026 Mihawk
|
|
SPDX-License-Identifier: MIT
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
import shutil
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
# ------------------------------------------------------------------ KTX 1
|
|
|
|
KTX_IDENTIFIER = bytes([0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
KTX_FORMATS = {
|
|
# name: glType, glTypeSize, glFormat, glInternalFormat, glBaseInternalFormat, bytes per pixel
|
|
"rgba8": (0x1401, 1, 0x1908, 0x8058, 0x1908, 4),
|
|
"r8": (0x1401, 1, 0x1903, 0x8229, 0x1903, 1),
|
|
"rgba16f": (0x140B, 2, 0x1908, 0x881A, 0x1908, 8),
|
|
}
|
|
|
|
|
|
def write_ktx(path, fmt, levels, faces=1, layers=0):
|
|
"""levels[l] is a list (array layers x faces, layer-major) of HxWxC arrays,
|
|
uint8 for rgba8/r8, float16 for rgba16f. A KTX 1 file as libktx reads it:
|
|
rows padded to 4 bytes, a cube face's image size given per face."""
|
|
gl_type, type_size, gl_format, internal, base, bpp = KTX_FORMATS[fmt]
|
|
height, width = levels[0][0].shape[:2]
|
|
out = bytearray(KTX_IDENTIFIER)
|
|
out += struct.pack("<13I", 0x04030201, gl_type, type_size, gl_format, internal, base,
|
|
width, height, 0, layers, faces, len(levels), 0)
|
|
for images in levels:
|
|
blobs = []
|
|
for image in images:
|
|
h, w = image.shape[:2]
|
|
row = w * bpp
|
|
padded = (row + 3) & ~3
|
|
data = np.ascontiguousarray(image).tobytes()
|
|
if padded != row:
|
|
data = b"".join(data[y * row:(y + 1) * row] + b"\0" * (padded - row) for y in range(h))
|
|
blobs.append(data)
|
|
size = len(blobs[0]) if (faces == 6 and layers == 0) else sum(len(b) for b in blobs)
|
|
out += struct.pack("<I", size)
|
|
for blob in blobs:
|
|
out += blob
|
|
out += b"\0" * ((4 - len(blob) % 4) % 4)
|
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(path).write_bytes(bytes(out))
|
|
|
|
|
|
def mip_chain(image, levels=None):
|
|
"""Box-filtered levels down to 1x1 (float math, rounded back to the input type)."""
|
|
chain = [image]
|
|
count = levels or (int(math.floor(math.log2(max(image.shape[:2])))) + 1)
|
|
current = image.astype(np.float32)
|
|
for _ in range(count - 1):
|
|
h, w = current.shape[:2]
|
|
nh, nw = max(1, h // 2), max(1, w // 2)
|
|
if h > 1 and w > 1:
|
|
current = 0.25 * (current[0:nh * 2:2, 0:nw * 2:2] + current[1:nh * 2:2, 0:nw * 2:2]
|
|
+ current[0:nh * 2:2, 1:nw * 2:2] + current[1:nh * 2:2, 1:nw * 2:2])
|
|
elif h > 1:
|
|
current = 0.5 * (current[0:nh * 2:2] + current[1:nh * 2:2])
|
|
else:
|
|
current = 0.5 * (current[:, 0:nw * 2:2] + current[:, 1:nw * 2:2])
|
|
if image.dtype == np.uint8:
|
|
chain.append(np.clip(np.rint(current), 0, 255).astype(np.uint8))
|
|
else:
|
|
chain.append(current.astype(image.dtype))
|
|
return chain
|
|
|
|
|
|
def rgba(image, size=None):
|
|
image = image.convert("RGBA")
|
|
if size:
|
|
image = image.resize((size, size), Image.LANCZOS)
|
|
return np.asarray(image, dtype=np.uint8)
|
|
|
|
|
|
def ktx_2d(path, array, fmt="rgba8", mips=True):
|
|
chain = mip_chain(array) if mips else [array]
|
|
write_ktx(path, fmt, [[level] for level in chain])
|
|
|
|
|
|
def ktx_array(path, arrays):
|
|
chains = [mip_chain(a) for a in arrays]
|
|
write_ktx(path, "rgba8", [[c[l] for c in chains] for l in range(len(chains[0]))], layers=len(arrays))
|
|
|
|
|
|
def ktx_cube(path, faces, fmt):
|
|
chains = [mip_chain(f) for f in faces]
|
|
write_ktx(path, fmt, [[c[l] for c in chains] for l in range(len(chains[0]))], faces=6)
|
|
|
|
|
|
# ------------------------------------------------------------------ glTF
|
|
|
|
def write_gltf(path, meshes):
|
|
"""meshes: dicts with pos, nrm, (uv), (tan), idx, color (RGBA material
|
|
factor), name. One node and one material a mesh; a .gltf and a .bin."""
|
|
path = Path(path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
blob = bytearray()
|
|
views, accessors, gl_meshes, nodes, materials = [], [], [], [], []
|
|
|
|
def add(array, kind, target, minmax=False):
|
|
array = np.ascontiguousarray(array)
|
|
offset = len(blob)
|
|
blob.extend(array.tobytes())
|
|
blob.extend(b"\0" * ((4 - len(blob) % 4) % 4))
|
|
views.append({"buffer": 0, "byteOffset": offset, "byteLength": array.nbytes, "target": target})
|
|
accessor = {"bufferView": len(views) - 1,
|
|
"componentType": 5125 if array.dtype == np.uint32 else 5126,
|
|
"count": int(array.shape[0]), "type": kind}
|
|
if minmax:
|
|
accessor["min"] = [float(v) for v in array.min(axis=0)]
|
|
accessor["max"] = [float(v) for v in array.max(axis=0)]
|
|
accessors.append(accessor)
|
|
return len(accessors) - 1
|
|
|
|
for i, mesh in enumerate(meshes):
|
|
attributes = {"POSITION": add(mesh["pos"].astype(np.float32), "VEC3", 34962, True),
|
|
"NORMAL": add(mesh["nrm"].astype(np.float32), "VEC3", 34962)}
|
|
if mesh.get("uv") is not None:
|
|
attributes["TEXCOORD_0"] = add(mesh["uv"].astype(np.float32), "VEC2", 34962)
|
|
if mesh.get("tan") is not None:
|
|
attributes["TANGENT"] = add(mesh["tan"].astype(np.float32), "VEC4", 34962)
|
|
indices = add(mesh["idx"].astype(np.uint32), "SCALAR", 34963)
|
|
materials.append({"name": mesh.get("name", f"material{i}"),
|
|
"pbrMetallicRoughness": {"baseColorFactor": [float(c) for c in mesh.get("color", (1, 1, 1, 1))],
|
|
"metallicFactor": 0.0, "roughnessFactor": 0.8}})
|
|
gl_meshes.append({"name": mesh.get("name", f"mesh{i}"),
|
|
"primitives": [{"attributes": attributes, "indices": indices, "material": i}]})
|
|
nodes.append({"name": mesh.get("name", f"node{i}"), "mesh": i})
|
|
bin_name = path.with_suffix(".bin").name
|
|
gltf = {"asset": {"version": "2.0", "generator": "PS5 Vulkan Template, ps5/tools/generate_assets.py"},
|
|
"scene": 0, "scenes": [{"nodes": list(range(len(nodes)))}], "nodes": nodes,
|
|
"meshes": gl_meshes, "materials": materials, "accessors": accessors, "bufferViews": views,
|
|
"buffers": [{"uri": bin_name, "byteLength": len(blob)}]}
|
|
path.with_suffix(".bin").write_bytes(bytes(blob))
|
|
path.write_text(json.dumps(gltf, indent=1) + "\n")
|
|
return path
|
|
|
|
|
|
def mesh(pos, nrm, idx, uv=None, color=(1, 1, 1, 1), name="mesh", tan=None):
|
|
return {"pos": np.asarray(pos, np.float32), "nrm": np.asarray(nrm, np.float32), "idx": np.asarray(idx, np.uint32),
|
|
"uv": None if uv is None else np.asarray(uv, np.float32), "color": color, "name": name,
|
|
"tan": None if tan is None else np.asarray(tan, np.float32)}
|
|
|
|
|
|
def transformed(m, scale=1.0, translate=(0, 0, 0), rotate_y=0.0):
|
|
c, s = math.cos(rotate_y), math.sin(rotate_y)
|
|
rot = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], np.float32)
|
|
scale = np.broadcast_to(np.asarray(scale, np.float32), (3,))
|
|
out = dict(m)
|
|
out["pos"] = (m["pos"] * scale) @ rot.T + np.asarray(translate, np.float32)
|
|
n = (m["nrm"] / scale) @ rot.T
|
|
out["nrm"] = n / np.linalg.norm(n, axis=1, keepdims=True)
|
|
return out
|
|
|
|
|
|
def merged(meshes, name, color):
|
|
pos, nrm, uv, idx, base = [], [], [], [], 0
|
|
for m in meshes:
|
|
pos.append(m["pos"]); nrm.append(m["nrm"])
|
|
uv.append(m["uv"] if m["uv"] is not None else np.zeros((len(m["pos"]), 2), np.float32))
|
|
idx.append(m["idx"] + base); base += len(m["pos"])
|
|
return mesh(np.concatenate(pos), np.concatenate(nrm), np.concatenate(idx), np.concatenate(uv), color, name)
|
|
|
|
|
|
# ------------------------------------------------------------------ shapes (glTF's Y up)
|
|
|
|
def grid_indices(rows, cols, flip=False):
|
|
idx = []
|
|
for r in range(rows):
|
|
for c in range(cols):
|
|
a, b = r * (cols + 1) + c, r * (cols + 1) + c + 1
|
|
d, e = (r + 1) * (cols + 1) + c, (r + 1) * (cols + 1) + c + 1
|
|
idx += [a, d, b, b, d, e] if not flip else [a, b, d, b, e, d]
|
|
return np.array(idx, np.uint32)
|
|
|
|
|
|
def uv_sphere(radius=1.0, rings=32, segments=64, inward=False):
|
|
pos, nrm, uv = [], [], []
|
|
for r in range(rings + 1):
|
|
v = r / rings
|
|
phi = v * math.pi
|
|
for s in range(segments + 1):
|
|
u = s / segments
|
|
theta = u * 2 * math.pi
|
|
n = (math.sin(phi) * math.cos(theta), math.cos(phi), math.sin(phi) * math.sin(theta))
|
|
pos.append([radius * x for x in n]); nrm.append([-x for x in n] if inward else n); uv.append((u, v))
|
|
return mesh(pos, nrm, grid_indices(rings, segments, flip=not inward), uv)
|
|
|
|
|
|
def box(size=(1, 1, 1), uv_scale=1.0):
|
|
sx, sy, sz = (s / 2 for s in size)
|
|
faces = [((1, 0, 0), (0, 0, -1), (0, 1, 0)), ((-1, 0, 0), (0, 0, 1), (0, 1, 0)),
|
|
((0, 1, 0), (1, 0, 0), (0, 0, -1)), ((0, -1, 0), (1, 0, 0), (0, 0, 1)),
|
|
((0, 0, 1), (1, 0, 0), (0, 1, 0)), ((0, 0, -1), (-1, 0, 0), (0, 1, 0))]
|
|
pos, nrm, uv, idx = [], [], [], []
|
|
half = np.array([sx, sy, sz])
|
|
for n, u, v in faces:
|
|
n, u, v = np.array(n), np.array(u), np.array(v)
|
|
base = len(pos)
|
|
for a, b in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
|
|
pos.append((n + a * u + b * v) * half); nrm.append(n); uv.append(((a + 1) / 2 * uv_scale, (1 - b) / 2 * uv_scale))
|
|
idx += [base, base + 1, base + 2, base, base + 2, base + 3]
|
|
return mesh(pos, nrm, idx, uv)
|
|
|
|
|
|
def cylinder(radius=1.0, height=1.0, segments=48, caps=True, inward=False, axis="y"):
|
|
pos, nrm, uv = [], [], []
|
|
for r in range(2):
|
|
y = (r - 0.5) * height
|
|
for s in range(segments + 1):
|
|
t = s / segments * 2 * math.pi
|
|
n = (math.cos(t), 0.0, math.sin(t))
|
|
pos.append((radius * n[0], y, radius * n[2])); nrm.append(tuple(-x for x in n) if inward else n)
|
|
uv.append((s / segments, r))
|
|
idx = list(grid_indices(1, segments, flip=inward))
|
|
if caps:
|
|
for top in (0, 1):
|
|
y = (top - 0.5) * height
|
|
center = len(pos)
|
|
pos.append((0, y, 0)); nrm.append((0, 1 if top else -1, 0)); uv.append((0.5, 0.5))
|
|
for s in range(segments + 1):
|
|
t = s / segments * 2 * math.pi
|
|
pos.append((radius * math.cos(t), y, radius * math.sin(t))); nrm.append((0, 1 if top else -1, 0))
|
|
uv.append((0.5 + 0.5 * math.cos(t), 0.5 + 0.5 * math.sin(t)))
|
|
for s in range(segments):
|
|
a, b = center + 1 + s, center + 2 + s
|
|
idx += [center, b, a] if top else [center, a, b]
|
|
m = mesh(pos, nrm, idx, uv)
|
|
if axis == "x":
|
|
m["pos"] = m["pos"][:, [1, 0, 2]] * np.array([1, 1, 1], np.float32)
|
|
m["nrm"] = m["nrm"][:, [1, 0, 2]]
|
|
m["idx"] = m["idx"].reshape(-1, 3)[:, [0, 2, 1]].reshape(-1)
|
|
return m
|
|
|
|
|
|
def cone(radius=1.0, height=1.0, segments=48):
|
|
pos, nrm, uv, idx = [], [], [], []
|
|
slope = radius / height
|
|
for s in range(segments + 1):
|
|
t = s / segments * 2 * math.pi
|
|
n = np.array([math.cos(t), slope, math.sin(t)]); n /= np.linalg.norm(n)
|
|
pos += [(radius * math.cos(t), -height / 2, radius * math.sin(t)), (0, height / 2, 0)]
|
|
nrm += [n, n]; uv += [(s / segments, 1), (s / segments, 0)]
|
|
for s in range(segments):
|
|
a = s * 2
|
|
idx += [a, a + 1, a + 2]
|
|
base = len(pos)
|
|
pos.append((0, -height / 2, 0)); nrm.append((0, -1, 0)); uv.append((0.5, 0.5))
|
|
for s in range(segments + 1):
|
|
t = s / segments * 2 * math.pi
|
|
pos.append((radius * math.cos(t), -height / 2, radius * math.sin(t))); nrm.append((0, -1, 0)); uv.append((0, 0))
|
|
for s in range(segments):
|
|
idx += [base, base + 1 + s, base + 2 + s]
|
|
return mesh(pos, nrm, idx, uv)
|
|
|
|
|
|
def torus_knot(p=2, q=3, radius=1.0, tube=0.28, segments=256, sides=24):
|
|
def curve(t):
|
|
r = radius * (2 + math.cos(q * t)) / 3
|
|
return np.array([r * math.cos(p * t), radius * math.sin(q * t) / 3 * 1.0, r * math.sin(p * t)])
|
|
pos, nrm, uv = [], [], []
|
|
for i in range(segments + 1):
|
|
t = i / segments * 2 * math.pi
|
|
c, d = curve(t), curve(t + 1e-3)
|
|
tangent = d - c; tangent /= np.linalg.norm(tangent)
|
|
normal = np.cross(tangent, (0, 1, 0)); normal /= np.linalg.norm(normal)
|
|
binormal = np.cross(tangent, normal)
|
|
for j in range(sides + 1):
|
|
a = j / sides * 2 * math.pi
|
|
n = math.cos(a) * normal + math.sin(a) * binormal
|
|
pos.append(c + tube * n); nrm.append(n); uv.append((i / segments * 8, j / sides))
|
|
return mesh(pos, nrm, grid_indices(segments, sides, flip=True), uv)
|
|
|
|
|
|
def plane(size=1.0, uv_repeat=1.0, y=0.0, divisions=1):
|
|
pos, nrm, uv, tan = [], [], [], []
|
|
for r in range(divisions + 1):
|
|
for c in range(divisions + 1):
|
|
u, v = c / divisions, r / divisions
|
|
pos.append(((u - 0.5) * size, y, (v - 0.5) * size)); nrm.append((0, 1, 0))
|
|
uv.append((u * uv_repeat, v * uv_repeat)); tan.append((1, 0, 0, 1))
|
|
return mesh(pos, nrm, grid_indices(divisions, divisions), uv, tan=tan)
|
|
|
|
|
|
def disc(radius=1.0, segments=96, uv_scale=1.0):
|
|
pos, nrm, uv = [(0, 0, 0)], [(0, 1, 0)], [(0.5 * uv_scale, 0.5 * uv_scale)]
|
|
for s in range(segments + 1):
|
|
t = s / segments * 2 * math.pi
|
|
pos.append((radius * math.cos(t), 0, radius * math.sin(t))); nrm.append((0, 1, 0))
|
|
uv.append(((0.5 + 0.5 * math.cos(t)) * uv_scale, (0.5 + 0.5 * math.sin(t)) * uv_scale))
|
|
idx = []
|
|
for s in range(segments):
|
|
idx += [0, s + 2, s + 1]
|
|
return mesh(pos, nrm, idx, uv)
|
|
|
|
|
|
def icosphere(subdivisions=3):
|
|
t = (1 + 5 ** 0.5) / 2
|
|
verts = [(-1, t, 0), (1, t, 0), (-1, -t, 0), (1, -t, 0), (0, -1, t), (0, 1, t), (0, -1, -t), (0, 1, -t),
|
|
(t, 0, -1), (t, 0, 1), (-t, 0, -1), (-t, 0, 1)]
|
|
verts = [np.array(v) / np.linalg.norm(v) for v in verts]
|
|
faces = [(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11), (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6),
|
|
(7, 1, 8), (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9), (4, 9, 5), (2, 4, 11), (6, 2, 10),
|
|
(8, 6, 7), (9, 8, 1)]
|
|
for _ in range(subdivisions):
|
|
cache, new = {}, []
|
|
def mid(a, b):
|
|
key = (min(a, b), max(a, b))
|
|
if key not in cache:
|
|
m = verts[a] + verts[b]; verts.append(m / np.linalg.norm(m)); cache[key] = len(verts) - 1
|
|
return cache[key]
|
|
for a, b, c in faces:
|
|
ab, bc, ca = mid(a, b), mid(b, c), mid(c, a)
|
|
new += [(a, ab, ca), (b, bc, ab), (c, ca, bc), (ab, bc, ca)]
|
|
faces = new
|
|
return np.array(verts, np.float32), np.array(faces, np.uint32)
|
|
|
|
|
|
def smooth_normals(pos, faces):
|
|
normals = np.zeros_like(pos)
|
|
tri = pos[faces]
|
|
fn = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
for k in range(3):
|
|
np.add.at(normals, faces[:, k], fn)
|
|
return normals / np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-9)
|
|
|
|
|
|
def value_noise_3d(points, seed, frequency):
|
|
"""Smooth pseudo-random noise at points (N,3): trilinear value noise."""
|
|
rng = np.random.default_rng(seed)
|
|
lattice = rng.random((32, 32, 32)).astype(np.float32)
|
|
p = points * frequency
|
|
i = np.floor(p).astype(int); f = p - i
|
|
f = f * f * (3 - 2 * f)
|
|
result = np.zeros(len(points), np.float32)
|
|
for dx in (0, 1):
|
|
for dy in (0, 1):
|
|
for dz in (0, 1):
|
|
w = (f[:, 0] if dx else 1 - f[:, 0]) * (f[:, 1] if dy else 1 - f[:, 1]) * (f[:, 2] if dz else 1 - f[:, 2])
|
|
result += w * lattice[(i[:, 0] + dx) % 32, (i[:, 1] + dy) % 32, (i[:, 2] + dz) % 32]
|
|
return result
|
|
|
|
|
|
def noise_2d(width, height, seed, octaves=5, scale=4.0, tile=True):
|
|
"""Tileable fractal value noise in [0, 1], HxW."""
|
|
rng = np.random.default_rng(seed)
|
|
out = np.zeros((height, width), np.float32)
|
|
amplitude, total = 1.0, 0.0
|
|
for o in range(octaves):
|
|
cells = int(scale * 2 ** o)
|
|
grid = rng.random((cells, cells)).astype(np.float32)
|
|
ys = np.arange(height) / height * cells
|
|
xs = np.arange(width) / width * cells
|
|
y0 = np.floor(ys).astype(int); x0 = np.floor(xs).astype(int)
|
|
fy = (ys - y0)[:, None]; fx = (xs - x0)[None, :]
|
|
fy = fy * fy * (3 - 2 * fy); fx = fx * fx * (3 - 2 * fx)
|
|
y1, x1 = (y0 + 1) % cells, (x0 + 1) % cells
|
|
y0, x0 = y0 % cells, x0 % cells
|
|
v = (grid[y0][:, x0] * (1 - fx) + grid[y0][:, x1] * fx) * (1 - fy) + \
|
|
(grid[y1][:, x0] * (1 - fx) + grid[y1][:, x1] * fx) * fy
|
|
out += amplitude * v; total += amplitude; amplitude *= 0.5
|
|
return out / total
|
|
|
|
|
|
# ------------------------------------------------------------------ HDR images
|
|
|
|
def read_hdr(path):
|
|
"""A Radiance RGBE (.hdr) file as an HxWx3 float32 array."""
|
|
data = Path(path).read_bytes()
|
|
header_end = data.index(b"\n\n") + 2
|
|
line_end = data.index(b"\n", header_end)
|
|
_, height, _, width = data[header_end:line_end].split()
|
|
height, width = int(height), int(width)
|
|
pos = line_end + 1
|
|
pixels = np.zeros((height, width, 4), np.uint8)
|
|
for y in range(height):
|
|
if data[pos] == 2 and data[pos + 1] == 2 and (data[pos + 2] << 8 | data[pos + 3]) == width:
|
|
pos += 4
|
|
for c in range(4):
|
|
x = 0
|
|
while x < width:
|
|
count = data[pos]; pos += 1
|
|
if count > 128:
|
|
count -= 128
|
|
pixels[y, x:x + count, c] = data[pos]; pos += 1
|
|
else:
|
|
pixels[y, x:x + count, c] = np.frombuffer(data[pos:pos + count], np.uint8); pos += count
|
|
x += count
|
|
else:
|
|
pixels[y] = np.frombuffer(data[pos:pos + width * 4], np.uint8).reshape(width, 4); pos += width * 4
|
|
exponent = pixels[..., 3].astype(np.int32)
|
|
scale = np.where(exponent > 0, np.ldexp(1.0, exponent - 136), 0.0).astype(np.float32)
|
|
return pixels[..., :3].astype(np.float32) * scale[..., None]
|
|
|
|
|
|
def cube_faces_from_equirect(equirect, size):
|
|
"""The six faces (+X, -X, +Y, -Y, +Z, -Z, Vulkan's order) of a cube map
|
|
sampled bilinearly from an equirectangular image. The samples' skyboxes
|
|
look the cube map up with positions whose Y the base class flipped (glTF's
|
|
Y is up, the samples' is down): the sky goes to -Y."""
|
|
h, w = equirect.shape[:2]
|
|
a = (np.arange(size) + 0.5) / size * 2 - 1
|
|
u, v = np.meshgrid(a, a)
|
|
ones = np.ones_like(u)
|
|
directions = [(ones, -v, -u), (-ones, -v, u), (u, ones, v), (u, -ones, -v), (u, -v, ones), (-u, -v, -ones)]
|
|
faces = []
|
|
for x, y, z in directions:
|
|
norm = np.sqrt(x * x + y * y + z * z)
|
|
x, y, z = x / norm, -y / norm, z / norm
|
|
lon = np.arctan2(x, -z)
|
|
lat = np.arcsin(np.clip(y, -1, 1))
|
|
fx = (lon / (2 * np.pi) + 0.5) * w - 0.5
|
|
fy = (0.5 - lat / np.pi) * h - 0.5
|
|
x0 = np.floor(fx).astype(int); y0 = np.floor(fy).astype(int)
|
|
tx = (fx - x0)[..., None]; ty = (fy - y0)[..., None]
|
|
x0m, x1m = x0 % w, (x0 + 1) % w
|
|
y0c, y1c = np.clip(y0, 0, h - 1), np.clip(y0 + 1, 0, h - 1)
|
|
face = (equirect[y0c, x0m] * (1 - tx) + equirect[y0c, x1m] * tx) * (1 - ty) + \
|
|
(equirect[y1c, x0m] * (1 - tx) + equirect[y1c, x1m] * tx) * ty
|
|
faces.append(face)
|
|
return faces
|
|
|
|
|
|
# ------------------------------------------------------------------ the generators
|
|
|
|
COLORS = {
|
|
"ground": (0.62, 0.62, 0.60, 1), "red": (0.80, 0.22, 0.18, 1), "orange": (0.92, 0.52, 0.16, 1),
|
|
"yellow": (0.90, 0.78, 0.24, 1), "green": (0.30, 0.66, 0.32, 1), "teal": (0.18, 0.62, 0.62, 1),
|
|
"blue": (0.24, 0.42, 0.82, 1), "purple": (0.56, 0.32, 0.74, 1), "white": (0.88, 0.88, 0.86, 1),
|
|
"stone": (0.72, 0.68, 0.60, 1),
|
|
}
|
|
|
|
|
|
def gen_cube(out, _):
|
|
return write_gltf(out / "models/ps5/cube.gltf", [dict(box((10, 10, 10)), name="cube")])
|
|
|
|
|
|
def gen_tunnel(out, _):
|
|
# 100 long on X, radius 2, seen from inside: as texturemipmapgen's camera expects
|
|
m = cylinder(radius=2.0, height=100.0, segments=64, caps=False, inward=True)
|
|
m["uv"] = np.stack([(m["pos"][:, 1] / 100.0 + 0.5) * 12.0,
|
|
np.arctan2(m["pos"][:, 2], m["pos"][:, 0]) / (2 * np.pi) * 2.0], axis=1)
|
|
m["pos"] = m["pos"][:, [1, 0, 2]]
|
|
m["nrm"] = m["nrm"][:, [1, 0, 2]]
|
|
m["idx"] = m["idx"].reshape(-1, 3)[:, [0, 2, 1]].reshape(-1)
|
|
return write_gltf(out / "models/ps5/tunnel.gltf", [dict(m, name="tunnel")])
|
|
|
|
|
|
def shadow_scene_columns():
|
|
parts = [dict(plane(size=22.0, divisions=8), color=COLORS["ground"], name="ground")]
|
|
colors = ["red", "orange", "yellow", "green", "teal", "blue", "purple", "white"]
|
|
for i, c in enumerate(colors):
|
|
a = i / len(colors) * 2 * math.pi
|
|
parts.append(dict(transformed(cylinder(0.45, 4.5), translate=(6.5 * math.cos(a), 2.25, 6.5 * math.sin(a))),
|
|
color=COLORS[c], name=f"column{i}"))
|
|
parts.append(dict(transformed(torus_knot(radius=2.4, tube=0.55), translate=(0, 2.6, 0)), color=COLORS["stone"], name="knot"))
|
|
parts.append(dict(transformed(box((2.0, 1.0, 2.0)), translate=(0, 0.5, 0)), color=COLORS["white"], name="plinth"))
|
|
return parts
|
|
|
|
|
|
def shadow_scene_shapes():
|
|
parts = [dict(plane(size=22.0, divisions=8), color=COLORS["ground"], name="ground")]
|
|
shapes = [(uv_sphere(1.2), (-4, 1.2, -3), "red"), (box((2, 2, 2)), (0, 1, -4), "yellow"),
|
|
(cone(1.2, 2.8), (4, 1.4, -3), "green"), (cylinder(0.9, 3.0), (-4.5, 1.5, 2.5), "blue"),
|
|
(torus_knot(radius=1.3, tube=0.35), (0, 1.6, 1.5), "orange"), (uv_sphere(0.8), (4.5, 0.8, 3), "purple"),
|
|
(box((1.2, 4.0, 1.2)), (1.8, 2.0, 5.2), "teal"), (box((5.0, 0.6, 1.4)), (-1.5, 0.3, 5.5), "white")]
|
|
for i, (shape, where, c) in enumerate(shapes):
|
|
parts.append(dict(transformed(shape, translate=where), color=COLORS[c], name=f"shape{i}"))
|
|
return parts
|
|
|
|
|
|
def gen_shadow_scenes(out, _):
|
|
write_gltf(out / "models/ps5/shadowscene_columns.gltf", shadow_scene_columns())
|
|
write_gltf(out / "models/ps5/shadowscene_shapes.gltf", shadow_scene_shapes())
|
|
return out / "models/ps5/shadowscene_columns.gltf"
|
|
|
|
|
|
def gen_imgui_scene(out, _):
|
|
# As small as the scene imgui's camera expects (about 3 units across)
|
|
models = [dict(transformed(torus_knot(radius=0.42, tube=0.1), translate=(0, 0.1, 0)), color=COLORS["orange"], name="knot"),
|
|
dict(transformed(uv_sphere(0.22), translate=(-0.62, -0.32, 0.35)), color=COLORS["red"], name="sphere"),
|
|
dict(transformed(box((0.4, 0.4, 0.4)), translate=(0.6, -0.34, 0.3), rotate_y=0.6), color=COLORS["blue"], name="box"),
|
|
dict(transformed(cone(0.22, 0.5), translate=(0.1, -0.29, 0.75)), color=COLORS["green"], name="cone")]
|
|
background = [dict(transformed(cylinder(1.4, 0.08, segments=96), translate=(0, -0.58, 0)), color=COLORS["ground"], name="floor"),
|
|
dict(transformed(cylinder(1.4, 2.4, segments=96, caps=False, inward=True), translate=(0, 0.62, 0)),
|
|
color=(0.30, 0.32, 0.38, 1), name="wall")]
|
|
rings = []
|
|
for i in range(12):
|
|
a = i / 12 * 2 * math.pi
|
|
rings.append(dict(transformed(uv_sphere(0.06, 12, 24), translate=(1.0 * math.cos(a), 1.05, 1.0 * math.sin(a))),
|
|
color=COLORS["yellow" if i % 2 else "teal"], name=f"light{i}"))
|
|
write_gltf(out / "models/ps5/imgui_models.gltf", models)
|
|
write_gltf(out / "models/ps5/imgui_background.gltf", background)
|
|
write_gltf(out / "models/ps5/imgui_ring.gltf", rings)
|
|
return out / "models/ps5/imgui_models.gltf"
|
|
|
|
|
|
def gen_instancing(out, download):
|
|
# A rock: a displaced icosphere 0.09 across, as the asteroid instancing expects
|
|
verts, faces = icosphere(3)
|
|
bumps = value_noise_3d(verts * 3 + 7, seed=11, frequency=1.0) * 0.45 + value_noise_3d(verts * 9, seed=12, frequency=1.0) * 0.15
|
|
verts = verts * (0.75 + bumps[:, None]) * np.array([1.0, 0.8, 0.9], np.float32) * 0.09
|
|
uv = np.stack([np.arctan2(verts[:, 2], verts[:, 0]) / (2 * np.pi) + 0.5,
|
|
np.arccos(np.clip(verts[:, 1] / np.linalg.norm(verts, axis=1), -1, 1)) / np.pi], axis=1)
|
|
write_gltf(out / "models/ps5/rock.gltf", [mesh(verts, smooth_normals(verts, faces), faces.reshape(-1), uv, name="rock")])
|
|
# The planet: a sphere of radius 4
|
|
write_gltf(out / "models/ps5/planet.gltf", [dict(uv_sphere(4.0, 64, 128), name="planet")])
|
|
# Its surface: cooled crust broken by glowing lava, made here from noise
|
|
n = noise_2d(1024, 512, seed=21, octaves=6, scale=6)
|
|
cracks = np.clip(1 - np.abs(noise_2d(1024, 512, seed=22, octaves=4, scale=8) - 0.5) * 9, 0, 1) ** 3
|
|
crust = np.stack([0.20 + 0.22 * n, 0.14 + 0.13 * n, 0.12 + 0.10 * n], axis=-1)
|
|
lava = np.stack([np.ones_like(n), 0.35 + 0.45 * n, 0.05 * np.ones_like(n)], axis=-1)
|
|
color = crust * (1 - cracks[..., None]) + lava * cracks[..., None]
|
|
image = np.concatenate([np.clip(color * 255, 0, 255), np.full((512, 1024, 1), 255)], axis=-1).astype(np.uint8)
|
|
image = np.asarray(Image.fromarray(image).resize((512, 512), Image.LANCZOS))
|
|
ktx_2d(out / "textures/ps5/lavaplanet_rgba.ktx", image)
|
|
# The rocks' colours: four CC0 rock surfaces from Poly Haven, one layer each
|
|
layers = [rgba(Image.open(download(url, sha)), 512) for url, sha in ROCK_TEXTURES]
|
|
ktx_array(out / "textures/ps5/texturearray_rocks_rgba.ktx", layers)
|
|
return out / "models/ps5/rock.gltf"
|
|
|
|
|
|
def leaf_layer(kind, seed):
|
|
"""One plant's picture, drawn here: 512x512 with alpha."""
|
|
rng = np.random.default_rng(seed)
|
|
image = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(image)
|
|
greens = [(52, 120, 40), (70, 140, 52), (92, 160, 60), (40, 100, 36)]
|
|
if kind == "grass":
|
|
for _ in range(60):
|
|
x = rng.uniform(40, 472); h = rng.uniform(220, 500); lean = rng.uniform(-60, 60); w = rng.uniform(6, 14)
|
|
g = greens[rng.integers(len(greens))]
|
|
draw.polygon([(x - w, 511), (x + w, 511), (x + lean, 511 - h)], fill=g + (255,))
|
|
elif kind in ("fern", "bush"):
|
|
for _ in range(26 if kind == "fern" else 70):
|
|
cx = rng.uniform(80, 432) if kind == "bush" else 256 + rng.uniform(-40, 40)
|
|
cy = rng.uniform(120, 470) if kind == "bush" else rng.uniform(80, 480)
|
|
rx, ry = (rng.uniform(30, 70), rng.uniform(16, 30)) if kind == "bush" else (rng.uniform(90, 200), rng.uniform(10, 18))
|
|
g = greens[rng.integers(len(greens))]
|
|
if kind == "fern":
|
|
side = 1 if rng.random() > 0.5 else -1
|
|
draw.ellipse([cx, cy - ry, cx + side * rx, cy + ry] if side > 0 else [cx - rx, cy - ry, cx, cy + ry], fill=g + (255,))
|
|
else:
|
|
draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=g + (255,))
|
|
if kind == "fern":
|
|
draw.line([(256, 511), (256, 70)], fill=(60, 90, 40, 255), width=8)
|
|
else: # flowers
|
|
for _ in range(24):
|
|
x = rng.uniform(60, 452); h = rng.uniform(200, 460)
|
|
draw.line([(x, 511), (x + rng.uniform(-20, 20), 511 - h)], fill=(60, 120, 50, 255), width=6)
|
|
petals = {"flower_red": (220, 60, 60), "flower_yellow": (240, 210, 70), "flower_blue": (110, 120, 230)}[kind]
|
|
for _ in range(18):
|
|
x = rng.uniform(60, 452); y = rng.uniform(60, 300); r = rng.uniform(16, 30)
|
|
draw.ellipse([x - r, y - r, x + r, y + r], fill=petals + (255,))
|
|
draw.ellipse([x - r / 3, y - r / 3, x + r / 3, y + r / 3], fill=(250, 230, 120, 255))
|
|
shading = (noise_2d(512, 512, seed=seed + 100, octaves=3, scale=6) * 0.5 + 0.75)
|
|
array = np.asarray(image).astype(np.float32)
|
|
array[..., :3] *= shading[..., None]
|
|
return np.clip(array, 0, 255).astype(np.uint8)
|
|
|
|
|
|
PLANT_KINDS = ["grass", "fern", "bush", "flower_red", "flower_yellow", "flower_blue"]
|
|
|
|
|
|
def gen_indirectdraw(out, download):
|
|
# One node a plant: two crossed quads, up to 0.5 tall (1.5 at the largest instance
|
|
# scale), the texture array layer by node order
|
|
plants = []
|
|
for i, kind in enumerate(PLANT_KINDS):
|
|
h = {"grass": 0.35, "fern": 0.5, "bush": 0.4}.get(kind, 0.45); w = h * 0.9
|
|
pos, nrm, uv, idx = [], [], [], []
|
|
for q in range(2):
|
|
a = q * math.pi / 2 + 0.3
|
|
dx, dz = math.cos(a) * w / 2, math.sin(a) * w / 2
|
|
base = len(pos)
|
|
pos += [(-dx, 0, -dz), (dx, 0, dz), (dx, h, dz), (-dx, h, -dz)]
|
|
n = (-math.sin(a), 0.4, math.cos(a))
|
|
nrm += [n] * 4
|
|
uv += [(0, 1), (1, 1), (1, 0), (0, 0)]
|
|
idx += [base, base + 1, base + 2, base, base + 2, base + 3, base, base + 2, base + 1, base, base + 3, base + 2]
|
|
m = mesh(pos, nrm, idx, uv, name=f"plant_{kind}")
|
|
m["nrm"] = m["nrm"] / np.linalg.norm(m["nrm"], axis=1, keepdims=True)
|
|
plants.append(m)
|
|
write_gltf(out / "models/ps5/plants.gltf", plants)
|
|
write_gltf(out / "models/ps5/ground_disc.gltf", [dict(disc(radius=25.0), name="ground")])
|
|
write_gltf(out / "models/ps5/skysphere.gltf", [dict(uv_sphere(1.0, 32, 64), name="skysphere")])
|
|
ktx_array(out / "textures/ps5/texturearray_plants_rgba.ktx", [leaf_layer(k, 40 + i) for i, k in enumerate(PLANT_KINDS)])
|
|
ktx_2d(out / "textures/ps5/ground_dry_rgba.ktx", rgba(Image.open(download(*GROUND_TEXTURE)), 512))
|
|
return out / "models/ps5/plants.gltf"
|
|
|
|
|
|
def gen_deferred_floor(out, download):
|
|
# 40 by 40 at y = -2.35, with tangents for the normal map, as deferred's floor
|
|
write_gltf(out / "models/ps5/floor.gltf", [dict(plane(size=40.0, uv_repeat=8.0, y=-2.35, divisions=4), name="floor")])
|
|
ktx_2d(out / "textures/ps5/cobblestone_color_rgba.ktx", rgba(Image.open(download(*COBBLE_COLOR))))
|
|
ktx_2d(out / "textures/ps5/cobblestone_normal_rgba.ktx", rgba(Image.open(download(*COBBLE_NORMAL))))
|
|
return out / "models/ps5/floor.gltf"
|
|
|
|
|
|
def gen_metal_plate(out, download):
|
|
# One level: texturemipmapgen makes the rest at run time
|
|
ktx_2d(out / "textures/ps5/metalplate_nomips_rgba.ktx", rgba(Image.open(download(*METAL_PLATE)), 1024), mips=False)
|
|
return out / "textures/ps5/metalplate_nomips_rgba.ktx"
|
|
|
|
|
|
def gen_particles(out, _):
|
|
a = (np.arange(64) + 0.5) / 64 * 2 - 1
|
|
x, y = np.meshgrid(a, a)
|
|
r = np.sqrt(x * x + y * y)
|
|
glow = np.clip(1 - r, 0, 1) ** 2.2
|
|
sprite = np.stack([glow * 255] * 4, axis=-1).astype(np.uint8)
|
|
ktx_2d(out / "textures/ps5/particle01_rgba.ktx", sprite, mips=False)
|
|
stops = [(0.0, (255, 250, 230)), (0.25, (255, 200, 80)), (0.5, (240, 90, 40)), (0.75, (120, 40, 140)), (1.0, (20, 30, 120))]
|
|
ramp = np.zeros((1, 256, 4), np.uint8)
|
|
for i in range(256):
|
|
t = i / 255
|
|
for (t0, c0), (t1, c1) in zip(stops, stops[1:]):
|
|
if t0 <= t <= t1:
|
|
f = (t - t0) / (t1 - t0)
|
|
ramp[0, i, :3] = [round(c0[k] + (c1[k] - c0[k]) * f) for k in range(3)]
|
|
ramp[0, i, 3] = 255
|
|
ktx_2d(out / "textures/ps5/particle_gradient_rgba.ktx", ramp, mips=False)
|
|
return out / "textures/ps5/particle01_rgba.ktx"
|
|
|
|
|
|
def gen_space_cubemap(out, _):
|
|
"""A starfield with faint nebulae, made here: no stitching, the stars are
|
|
placed on the sphere and each face draws the ones in front of it."""
|
|
size = 1024
|
|
rng = np.random.default_rng(31)
|
|
stars = rng.normal(size=(9000, 3)); stars /= np.linalg.norm(stars, axis=1, keepdims=True)
|
|
brightness = rng.random(9000) ** 6
|
|
tint = rng.random((9000, 3)) * 0.25 + 0.75
|
|
faces = []
|
|
a = (np.arange(size) + 0.5) / size * 2 - 1
|
|
u, v = np.meshgrid(a, a)
|
|
ones = np.ones_like(u)
|
|
axes = [(ones, -v, -u), (-ones, -v, u), (u, ones, v), (u, -ones, -v), (u, -v, ones), (-u, -v, -ones)]
|
|
face_frames = [((1, 0, 0), (0, 0, -1), (0, -1, 0)), ((-1, 0, 0), (0, 0, 1), (0, -1, 0)), ((0, 1, 0), (1, 0, 0), (0, 0, 1)),
|
|
((0, -1, 0), (1, 0, 0), (0, 0, -1)), ((0, 0, 1), (1, 0, 0), (0, -1, 0)), ((0, 0, -1), (-1, 0, 0), (0, -1, 0))]
|
|
for (x, y, z), (normal, right, down) in zip(axes, face_frames):
|
|
d = np.stack([x, y, z], -1); d /= np.linalg.norm(d, axis=-1, keepdims=True)
|
|
nebula = value_noise_3d(d.reshape(-1, 3) * 2.5 + 5, seed=33, frequency=1.0).reshape(size, size)
|
|
nebula2 = value_noise_3d(d.reshape(-1, 3) * 5 + 9, seed=34, frequency=1.0).reshape(size, size)
|
|
image = np.zeros((size, size, 3), np.float32)
|
|
image += np.clip(nebula - 0.55, 0, 1)[..., None] * np.array([90, 40, 140]) * 2.2
|
|
image += np.clip(nebula2 - 0.6, 0, 1)[..., None] * np.array([30, 80, 150]) * 2.2
|
|
normal, right, down = map(np.array, (normal, right, down))
|
|
facing = stars @ normal
|
|
front = facing > 0
|
|
sx = (stars[front] @ right) / facing[front]; sy = (stars[front] @ down) / facing[front]
|
|
px = ((sx + 1) / 2 * size).astype(int); py = ((sy + 1) / 2 * size).astype(int)
|
|
inside = (px >= 0) & (px < size) & (py >= 0) & (py < size)
|
|
for X, Y, b, t in zip(px[inside], py[inside], brightness[front][inside], tint[front][inside]):
|
|
image[Y, X] += 255 * (0.25 + 0.75 * b) * t
|
|
if b > 0.5:
|
|
for ox, oy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
if 0 <= X + ox < size and 0 <= Y + oy < size:
|
|
image[Y + oy, X + ox] += 120 * b * t
|
|
faces.append(np.concatenate([np.clip(image, 0, 255), np.full((size, size, 1), 255)], -1).astype(np.uint8))
|
|
# One level, as the file it replaces: the skybox is seen at about one texel a pixel
|
|
write_ktx(out / "textures/ps5/cubemap_space.ktx", "rgba8", [faces], faces=6)
|
|
return out / "textures/ps5/cubemap_space.ktx"
|
|
|
|
|
|
def gen_hdri_cube(out, download):
|
|
equirect = read_hdr(download(*HDRI))
|
|
# Half floats end at 65504: the sun's brightest texels are clamped below it
|
|
faces = [np.minimum(f, 60000.0).astype(np.float32) for f in cube_faces_from_equirect(equirect, 512)]
|
|
faces = [np.concatenate([f, np.ones(f.shape[:2] + (1,), np.float32)], -1).astype(np.float16) for f in faces]
|
|
ktx_cube(out / "textures/ps5/hdr/kloofendal_cube.ktx", faces, "rgba16f")
|
|
return out / "textures/ps5/hdr/kloofendal_cube.ktx"
|
|
|
|
|
|
def compute_tangents(pos, nrm, uv, idx):
|
|
tan = np.zeros((len(pos), 3)); bit = np.zeros((len(pos), 3))
|
|
tri = idx.reshape(-1, 3)
|
|
p0, p1, p2 = pos[tri[:, 0]], pos[tri[:, 1]], pos[tri[:, 2]]
|
|
w0, w1, w2 = uv[tri[:, 0]], uv[tri[:, 1]], uv[tri[:, 2]]
|
|
e1, e2 = p1 - p0, p2 - p0
|
|
d1, d2 = w1 - w0, w2 - w0
|
|
r = d1[:, 0] * d2[:, 1] - d2[:, 0] * d1[:, 1]
|
|
r = np.where(np.abs(r) < 1e-12, 1e-12, r)
|
|
sdir = (e1 * d2[:, 1:2] - e2 * d1[:, 1:2]) / r[:, None]
|
|
tdir = (e2 * d1[:, 0:1] - e1 * d2[:, 0:1]) / r[:, None]
|
|
for k in range(3):
|
|
np.add.at(tan, tri[:, k], sdir); np.add.at(bit, tri[:, k], tdir)
|
|
t = tan - nrm * np.sum(nrm * tan, axis=1, keepdims=True)
|
|
t /= np.maximum(np.linalg.norm(t, axis=1, keepdims=True), 1e-9)
|
|
w = np.where(np.sum(np.cross(nrm, t) * bit, axis=1) < 0, -1.0, 1.0)
|
|
return np.concatenate([t, w[:, None]], axis=1).astype(np.float32)
|
|
|
|
|
|
def read_accessor(gltf, binary, index):
|
|
accessor = gltf["accessors"][index]
|
|
view = gltf["bufferViews"][accessor["bufferView"]]
|
|
count = accessor["count"]
|
|
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
|
|
dtype = {5126: np.float32, 5125: np.uint32, 5123: np.uint16, 5121: np.uint8}[accessor["componentType"]]
|
|
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
|
|
stride = view.get("byteStride", 0) or width * np.dtype(dtype).itemsize
|
|
raw = np.frombuffer(binary, np.uint8, count=stride * (count - 1) + width * np.dtype(dtype).itemsize, offset=offset)
|
|
out = np.lib.stride_tricks.as_strided(raw.view(np.uint8), shape=(count, width * np.dtype(dtype).itemsize), strides=(stride, 1))
|
|
return np.ascontiguousarray(out).view(dtype).reshape(count, width) if width > 1 else np.ascontiguousarray(out).view(dtype).reshape(count)
|
|
|
|
|
|
def gen_pbr_camera(out, download):
|
|
"""Poly Haven's CC0 vintage video camera, made into what pbrtexture reads:
|
|
one glTF with tangents, about 1.4 across like the model it replaces, and
|
|
its maps as separate KTX files (albedo and normal RGBA, the rest R8)."""
|
|
gltf = json.loads(Path(download(*CAMERA["gltf"])).read_text())
|
|
binary = Path(download(*CAMERA["bin"])).read_bytes()
|
|
parts = []
|
|
for m in gltf["meshes"]:
|
|
for p in m["primitives"]:
|
|
a = p["attributes"]
|
|
pos = read_accessor(gltf, binary, a["POSITION"]).astype(np.float64)
|
|
nrm = read_accessor(gltf, binary, a["NORMAL"]).astype(np.float64)
|
|
uv = read_accessor(gltf, binary, a["TEXCOORD_0"]).astype(np.float64)
|
|
idx = read_accessor(gltf, binary, p["indices"]).astype(np.uint32)
|
|
parts.append((pos, nrm, uv, idx))
|
|
lo = np.min([p[0].min(axis=0) for p in parts], axis=0); hi = np.max([p[0].max(axis=0) for p in parts], axis=0)
|
|
center, scale = (lo + hi) / 2, 1.4 / np.max(hi - lo)
|
|
meshes = []
|
|
for i, (pos, nrm, uv, idx) in enumerate(parts):
|
|
pos = (pos - center) * scale
|
|
meshes.append(mesh(pos, nrm, idx, uv, name=f"camera{i}", tan=compute_tangents(pos, nrm, uv, idx)))
|
|
folder = out / "models/ps5/videocamera"
|
|
write_gltf(folder / "videocamera.gltf", meshes)
|
|
# 1024x1024: the camera covers a third of the 4K screen at most
|
|
ktx_2d(folder / "albedo.ktx", rgba(Image.open(download(*CAMERA["diff"])), 1024))
|
|
ktx_2d(folder / "normal.ktx", rgba(Image.open(download(*CAMERA["nor_gl"])), 1024))
|
|
for name, key in (("ao", "ao"), ("metallic", "metal"), ("roughness", "rough")):
|
|
gray = np.asarray(Image.open(download(*CAMERA[key])).convert("L").resize((1024, 1024), Image.LANCZOS), np.uint8)
|
|
chain = mip_chain(gray[..., None])
|
|
write_ktx(folder / f"{name}.ktx", "r8", [[level] for level in chain])
|
|
return folder
|
|
|
|
|
|
def node_matrix(node):
|
|
if "matrix" in node:
|
|
return np.array(node["matrix"], np.float64).reshape(4, 4).T
|
|
t = np.eye(4); t[:3, 3] = node.get("translation", [0, 0, 0])
|
|
x, y, z, w = node.get("rotation", [0, 0, 0, 1])
|
|
r = np.eye(4)
|
|
r[:3, :3] = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
|
|
sc = np.diag(list(node.get("scale", [1, 1, 1])) + [1])
|
|
return t @ r @ sc
|
|
|
|
|
|
def world_bounds(gltf):
|
|
"""The scene's bounding box: each mesh's POSITION min and max through its nodes' transforms."""
|
|
lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
|
|
def visit(index, parent):
|
|
node = gltf["nodes"][index]
|
|
m = parent @ node_matrix(node)
|
|
nonlocal lo, hi
|
|
if "mesh" in node:
|
|
for p in gltf["meshes"][node["mesh"]]["primitives"]:
|
|
a = gltf["accessors"][p["attributes"]["POSITION"]]
|
|
for corner in np.array(np.meshgrid(*zip(a["min"], a["max"]))).T.reshape(-1, 3):
|
|
w = m @ np.append(corner, 1)
|
|
lo, hi = np.minimum(lo, w[:3]), np.maximum(hi, w[:3])
|
|
for child in node.get("children", []):
|
|
visit(child, m)
|
|
for root in gltf["scenes"][gltf.get("scene", 0)]["nodes"]:
|
|
visit(root, np.eye(4))
|
|
return lo, hi
|
|
|
|
|
|
def gen_lantern(out, download):
|
|
"""Khronos's CC0 Lantern for multisampling, dynamicrendering and the
|
|
starter, scaled to what each one's camera frames (5.5, 8 and 1.6 tall) and
|
|
centred where it looks
|
|
(multisampling's camera is turned 90 degrees and offset by (2.5, 2.5): it
|
|
looks through (0, 2.5, 2.5) in glTF's axes; dynamicrendering's through
|
|
the origin). A parent node does both, in a .gltf of each sample's beside
|
|
Khronos's own buffer and images."""
|
|
folder = out / "models/ps5/lantern"
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
gltf = json.loads(Path(download(*LANTERN["Lantern.gltf"])).read_text())
|
|
for name in [b["uri"] for b in gltf["buffers"]] + [i["uri"] for i in gltf.get("images", [])]:
|
|
shutil.copyfile(download(*LANTERN[name]), folder / name)
|
|
scene = gltf["scenes"][gltf.get("scene", 0)]
|
|
roots = list(scene["nodes"])
|
|
lo, hi = world_bounds(gltf)
|
|
center = (lo + hi) / 2
|
|
# (multisampling's is turned a quarter so its camera sees the lantern beside the post)
|
|
for name, height, target, turn in (("lantern_multisampling", 5.5, (0.0, 2.5, 2.5), math.pi / 2),
|
|
("lantern_dynamicrendering", 8.0, (0.0, 0.0, 0.0), 0.0),
|
|
("lantern_starter", 1.6, (0.0, 0.0, 0.0), 0.0)):
|
|
variant = json.loads(json.dumps(gltf))
|
|
scale = height / (hi[1] - lo[1])
|
|
c, s_ = math.cos(turn), math.sin(turn)
|
|
turned = np.array([c * center[0] + s_ * center[2], center[1], -s_ * center[0] + c * center[2]])
|
|
variant["nodes"].append({"name": "ps5_place", "children": roots, "scale": [scale] * 3,
|
|
"rotation": [0.0, math.sin(turn / 2), 0.0, math.cos(turn / 2)],
|
|
"translation": [float(t - v * scale) for t, v in zip(target, turned)]})
|
|
variant["scenes"][variant.get("scene", 0)]["nodes"] = [len(variant["nodes"]) - 1]
|
|
(folder / f"{name}.gltf").write_text(json.dumps(variant, indent=1) + "\n")
|
|
return folder
|
|
|
|
|
|
GENERATORS = {
|
|
"cube": gen_cube, "tunnel": gen_tunnel, "shadow_scenes": gen_shadow_scenes, "imgui_scene": gen_imgui_scene,
|
|
"instancing": gen_instancing, "indirectdraw": gen_indirectdraw, "deferred_floor": gen_deferred_floor,
|
|
"metal_plate": gen_metal_plate, "particles": gen_particles, "space_cubemap": gen_space_cubemap,
|
|
"hdri_cube": gen_hdri_cube, "pbr_camera": gen_pbr_camera, "lantern": gen_lantern,
|
|
}
|
|
|
|
# The downloads (URL, SHA-256): CC0, Poly Haven and Khronos (ps5/assets.json)
|
|
PH = "https://dl.polyhaven.org/file/ph-assets/"
|
|
KHRONOS = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/f36bfdabd1031c3cf6689a50570b8cdf3678b49c/Models/"
|
|
HDRI = (PH + "HDRIs/hdr/2k/kloofendal_48d_partly_cloudy_2k.hdr", "3fbd33f279f29bd64925c1dd3214fd46c627493d21f1100248b6b1098da4d06e")
|
|
METAL_PLATE = (PH + "Textures/jpg/1k/metal_plate/metal_plate_diff_1k.jpg", "c6b6739eac2c75ccc898e753d7833df24eafbdb9aa92fb6eabfb92abd1a680a5")
|
|
COBBLE_COLOR = (PH + "Textures/jpg/1k/cobblestone_floor_01/cobblestone_floor_01_diff_1k.jpg", "7630d6a59501cab4d2295b2be47bad892b8dae49895b4bee6bc26fd45c423e05")
|
|
COBBLE_NORMAL = (PH + "Textures/jpg/1k/cobblestone_floor_01/cobblestone_floor_01_nor_gl_1k.jpg", "e8a07bd38fdaeb92a419bc4829631847df7c03fd657dd32b44f19e2550a4fb35")
|
|
GROUND_TEXTURE = (PH + "Textures/jpg/1k/dry_ground_01/dry_ground_01_diff_1k.jpg", "75222fc97a82b635a09cf8f2891dd58bc42e49599b9fda920b2c50193fe85e9f")
|
|
ROCK_TEXTURES = [(PH + f"Textures/jpg/1k/{r}/{r}_diff_1k.jpg", h) for r, h in (
|
|
("rock_boulder_dry", "20f653a42fb24f42aa40641663070d578678da49af18cc1049dbdd4ed8679f3c"), ("rock_face", "cce4b50517161264bdef196f5e247e328ca3739083cd6044ad3cc54d88cb82e2"),
|
|
("aerial_rocks_02", "708373b2af7fd07ac8de1500556006aaf904c41a99985a849cacc78874f38eac"), ("dry_riverbed_rock", "5dc1fd5777f613451a703d2fc292d4a4e79987f90b991efa98c7ca02458c6a8a"))]
|
|
CAMERA = {
|
|
"gltf": (PH + "Models/gltf/2k/vintage_video_camera/vintage_video_camera_2k.gltf", "3866057e38a97ac33cba4fec3bd04efff5cf7d1f6f4bc121285bf5b700e7438a"),
|
|
"bin": (PH + "Models/gltf/8k/vintage_video_camera/vintage_video_camera.bin", "1c92781aba4557dda38c66b1eb328fdb2cc0869e5af40413d69de13a9e28d9f4"),
|
|
**{m: (PH + f"Models/jpg/2k/vintage_video_camera/vintage_video_camera_{m}_2k.jpg", h) for m, h in (
|
|
("diff", "8a394452ca50544f4201571e1fa6801c2a3afe6b4f7f7dc5d0db85bd58e7b0d4"), ("nor_gl", "273d8841d4d694d928e0913ebd0d4a41cefd5d71a5ba3c864a0a3bc6ee750d86"), ("ao", "d76441c0ace6d353c9f7d62a77b3c3e891f51886bc2a1fb4c948fdda50ba4dcd"),
|
|
("rough", "85d3ac5b642db3d2fb099111a5c352cf5cb97436fe4b90a33a4a50e95fe38b52"), ("metal", "a576883de1ad5a4be9112adaa4061c992670f6a249fcb3425e4d19d00e39a7f5"))},
|
|
}
|
|
LANTERN = {name: (KHRONOS + "Lantern/glTF/" + name, h) for name, h in (
|
|
("Lantern.gltf", "2106fea76c458caf43078c34ae7da7016cedd1f22e6f4badf94590be590f53d6"), ("Lantern.bin", "ef0e2e89e4b14ff9035b43867a16dc3218569b29363ccb16b283b86f1dc50eb0"),
|
|
("Lantern_baseColor.png", "a2d6aa660f0b9ce46b3863e955248454e73be96063218b799af9f750e0f7be71"), ("Lantern_roughnessMetallic.png", "01b8105756fd86f13e66602f85ee03e7e5daf22a8531f32bef8bbe678cf38cf6"),
|
|
("Lantern_normal.png", "3318a4d3bef8be53c192d87b7401145493774126c54a22e50e1269f9476065fa"), ("Lantern_emissive.png", "eafa6390501d537f5bbcf92c64ad1c39d2493aef4508e9cc9d537771efe8bad7"))}
|
|
|
|
|
|
def build(name, out_dir, download):
|
|
"""Run the generator `name` into out_dir, unless it ran already with this
|
|
version of this file (a stamp records the file's hash)."""
|
|
import hashlib
|
|
out_dir = Path(out_dir)
|
|
version = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
|
stamp = out_dir / ".stamps" / name
|
|
if stamp.is_file() and stamp.read_text() == version:
|
|
return
|
|
print(f"generate {name}")
|
|
GENERATORS[name](out_dir, download)
|
|
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
stamp.write_text(version)
|