Draw prop models with a single atlas material
Each generated prop used one material per part (body, accent, metal...). The model pipeline now packs a prop's palette into one power-of-two colour atlas plus a matching metallic/roughness atlas and emits a single material: - atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat. - build_glb(): every part merges into one primitive with constant per-vertex atlas UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0. - audit_glb(): fails unless a prop has exactly one material, both atlas textures, two images, and TEXCOORD_0 on every primitive. - Removed the now-unused sRGB->linear factor helpers; colour travels in the texture. Verification: headless 181 -> 182 (new "one material per prop scene" test), Python tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old factor tests), model audit OK for all six props, screenshots regenerated and screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
This commit is contained in:
1 parent
7f92b8861d
commit
f2863abeac
32 files changed
+275
-97
No files matched your search
+181
-67
@@ -475,99 +475,196 @@ def generate_concepts(only="", model=DEFAULT_CONCEPT_MODEL, size=1024,
|
||||
# glTF binary writer.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ATLAS_CELL = 8
|
||||
|
||||
|
||||
def _next_pow2(value: int) -> int:
|
||||
size = 1
|
||||
while size < value:
|
||||
size *= 2
|
||||
return size
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int, rgba: bytearray) -> bytes:
|
||||
"""Encode raw RGBA rows as a PNG without requiring Pillow."""
|
||||
import zlib
|
||||
rows = bytearray()
|
||||
stride = width * 4
|
||||
for y in range(height):
|
||||
rows.append(0) # filter type 0
|
||||
rows.extend(rgba[y * stride:(y + 1) * stride])
|
||||
|
||||
def chunk(tag: bytes, payload: bytes) -> bytes:
|
||||
return (struct.pack(">I", len(payload)) + tag + payload +
|
||||
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
|
||||
|
||||
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
|
||||
chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def atlas_for(prop: dict, palette: dict = None) -> dict:
|
||||
"""Pack every colour this prop uses into one tiny texture atlas.
|
||||
|
||||
Each part gets a constant UV pointing at its palette cell, so all parts can
|
||||
share a single material instead of one material per part. The same layout
|
||||
is written into a metallic/roughness atlas (glTF reads roughness from G and
|
||||
metallic from B), keeping the per-role surface response.
|
||||
"""
|
||||
palette = palette or PALETTE
|
||||
slots = {}
|
||||
for part in prop["parts"]:
|
||||
role = part.get("role", "body")
|
||||
colour = tuple(round(float(c), 5)
|
||||
for c in palette.get(role, PALETTE[role])[:3])
|
||||
metallic, roughness = PBR.get(role, PBR["body"])
|
||||
slots[role] = (colour, metallic, roughness)
|
||||
roles = sorted(slots.keys())
|
||||
grid = max(1, int(math.ceil(math.sqrt(len(roles)))))
|
||||
size = _next_pow2(grid * ATLAS_CELL)
|
||||
base = bytearray(size * size * 4)
|
||||
metal_rough = bytearray(size * size * 4)
|
||||
role_uv = {}
|
||||
for index, role in enumerate(roles):
|
||||
colour, metallic, roughness = slots[role]
|
||||
col = index % grid
|
||||
row = index // grid
|
||||
x0 = col * ATLAS_CELL
|
||||
y0 = row * ATLAS_CELL
|
||||
rgb = bytes((int(round(colour[0] * 255)), int(round(colour[1] * 255)),
|
||||
int(round(colour[2] * 255)), 255))
|
||||
mr = bytes((0, int(round(roughness * 255)), int(round(metallic * 255)), 255))
|
||||
for y in range(ATLAS_CELL):
|
||||
for x in range(ATLAS_CELL):
|
||||
offset = ((y0 + y) * size + (x0 + x)) * 4
|
||||
base[offset:offset + 4] = rgb
|
||||
metal_rough[offset:offset + 4] = mr
|
||||
role_uv[role] = ((x0 + ATLAS_CELL / 2.0) / size,
|
||||
(y0 + ATLAS_CELL / 2.0) / size)
|
||||
return {
|
||||
"size": size,
|
||||
"roles": roles,
|
||||
"role_uv": role_uv,
|
||||
"colours": {role: slots[role][0] for role in roles},
|
||||
"base_png": _png_bytes(size, size, base),
|
||||
"metal_rough_png": _png_bytes(size, size, metal_rough),
|
||||
}
|
||||
|
||||
|
||||
def _pad(data: bytes, fill: bytes) -> bytes:
|
||||
remainder = len(data) % 4
|
||||
return data if remainder == 0 else data + fill * (4 - remainder)
|
||||
|
||||
|
||||
def srgb_to_linear(channel: float) -> float:
|
||||
"""Convert one 0..1 sRGB channel to linear.
|
||||
def build_glb(prop: dict, palette: dict = None, atlas: dict = None) -> bytes:
|
||||
"""Serialise one prop as a self-contained .glb (glTF 2.0) file.
|
||||
|
||||
glTF stores `baseColorFactor` in linear space, while the catalogue palettes
|
||||
(and the colours sampled from a concept PNG) are sRGB. Writing sRGB numbers
|
||||
straight into the factor makes every prop render several shades too light,
|
||||
which is why the first build's bins and newspaper boxes looked pastel.
|
||||
All parts share one material backed by a colour atlas and a
|
||||
metallic/roughness atlas, so the prop draws with a single material.
|
||||
"""
|
||||
if channel <= 0.04045:
|
||||
return channel / 12.92
|
||||
return ((channel + 0.055) / 1.055) ** 2.4
|
||||
|
||||
|
||||
def _linear_rgba(color) -> list:
|
||||
return [round(srgb_to_linear(float(c)), 5) for c in color[:3]] + [1.0]
|
||||
|
||||
|
||||
def build_glb(prop: dict, palette: dict = None) -> bytes:
|
||||
"""Serialise one prop as a self-contained .glb (glTF 2.0) file."""
|
||||
palette = palette or PALETTE
|
||||
atlas = atlas or atlas_for(prop, palette)
|
||||
|
||||
positions = []
|
||||
normals = []
|
||||
uvs = []
|
||||
indices = []
|
||||
for part in prop["parts"]:
|
||||
mesh = build_part_mesh(part)
|
||||
base = len(positions)
|
||||
uv = atlas["role_uv"][part.get("role", "body")]
|
||||
for position, normal in zip(mesh.positions, mesh.normals):
|
||||
positions.append(position)
|
||||
normals.append(normal)
|
||||
uvs.append(uv)
|
||||
indices.extend(index + base for index in mesh.indices)
|
||||
|
||||
binary = bytearray()
|
||||
buffer_views = []
|
||||
accessors = []
|
||||
materials = []
|
||||
primitives = []
|
||||
|
||||
def add_view(payload: bytes, target: int) -> int:
|
||||
buffer_views.append({
|
||||
def add_view(payload: bytes, target=None) -> int:
|
||||
view = {
|
||||
"buffer": 0,
|
||||
"byteOffset": len(binary),
|
||||
"byteLength": len(payload),
|
||||
"target": target,
|
||||
})
|
||||
}
|
||||
if target is not None:
|
||||
view["target"] = target
|
||||
buffer_views.append(view)
|
||||
binary.extend(_pad(payload, b"\x00"))
|
||||
return len(buffer_views) - 1
|
||||
|
||||
# One primitive per part: the parts share a mesh but need separate
|
||||
# materials, and a glTF primitive is the unit of material assignment.
|
||||
for part in prop["parts"]:
|
||||
mesh = build_part_mesh(part)
|
||||
positions = b"".join(struct.pack("<3f", *p) for p in mesh.positions)
|
||||
normals = b"".join(struct.pack("<3f", *n) for n in mesh.normals)
|
||||
indices = b"".join(struct.pack("<H", i) for i in mesh.indices)
|
||||
pos_view = add_view(positions, 34962)
|
||||
nrm_view = add_view(normals, 34962)
|
||||
idx_view = add_view(indices, 34963)
|
||||
pos_payload = b"".join(struct.pack("<3f", *p) for p in positions)
|
||||
nrm_payload = b"".join(struct.pack("<3f", *n) for n in normals)
|
||||
uv_payload = b"".join(struct.pack("<2f", *uv) for uv in uvs)
|
||||
idx_payload = b"".join(struct.pack("<H", i) for i in indices)
|
||||
|
||||
xs = [p[0] for p in mesh.positions] or [0.0]
|
||||
ys = [p[1] for p in mesh.positions] or [0.0]
|
||||
zs = [p[2] for p in mesh.positions] or [0.0]
|
||||
pos_accessor = len(accessors)
|
||||
accessors.append({"bufferView": pos_view, "componentType": 5126,
|
||||
"count": len(mesh.positions), "type": "VEC3",
|
||||
"min": [min(xs), min(ys), min(zs)],
|
||||
"max": [max(xs), max(ys), max(zs)]})
|
||||
nrm_accessor = len(accessors)
|
||||
accessors.append({"bufferView": nrm_view, "componentType": 5126,
|
||||
"count": len(mesh.normals), "type": "VEC3"})
|
||||
idx_accessor = len(accessors)
|
||||
accessors.append({"bufferView": idx_view, "componentType": 5123,
|
||||
"count": len(mesh.indices), "type": "SCALAR"})
|
||||
pos_view = add_view(pos_payload, 34962)
|
||||
nrm_view = add_view(nrm_payload, 34962)
|
||||
uv_view = add_view(uv_payload, 34962)
|
||||
idx_view = add_view(idx_payload, 34963)
|
||||
|
||||
role = part.get("role", "body")
|
||||
color = palette.get(role, PALETTE[role])
|
||||
metallic, roughness = PBR.get(role, PBR["body"])
|
||||
materials.append({
|
||||
"name": role,
|
||||
"doubleSided": False,
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorFactor": _linear_rgba(color),
|
||||
"metallicFactor": metallic,
|
||||
"roughnessFactor": roughness,
|
||||
},
|
||||
})
|
||||
primitives.append({
|
||||
"attributes": {"POSITION": pos_accessor, "NORMAL": nrm_accessor},
|
||||
"indices": idx_accessor,
|
||||
"material": len(materials) - 1,
|
||||
})
|
||||
xs = [p[0] for p in positions] or [0.0]
|
||||
ys = [p[1] for p in positions] or [0.0]
|
||||
zs = [p[2] for p in positions] or [0.0]
|
||||
pos_accessor = len(accessors)
|
||||
accessors.append({"bufferView": pos_view, "componentType": 5126,
|
||||
"count": len(positions), "type": "VEC3",
|
||||
"min": [min(xs), min(ys), min(zs)],
|
||||
"max": [max(xs), max(ys), max(zs)]})
|
||||
nrm_accessor = len(accessors)
|
||||
accessors.append({"bufferView": nrm_view, "componentType": 5126,
|
||||
"count": len(normals), "type": "VEC3"})
|
||||
uv_accessor = len(accessors)
|
||||
accessors.append({"bufferView": uv_view, "componentType": 5126,
|
||||
"count": len(uvs), "type": "VEC2"})
|
||||
idx_accessor = len(accessors)
|
||||
accessors.append({"bufferView": idx_view, "componentType": 5123,
|
||||
"count": len(indices), "type": "SCALAR"})
|
||||
|
||||
base_view = add_view(atlas["base_png"])
|
||||
mr_view = add_view(atlas["metal_rough_png"])
|
||||
|
||||
gltf = {
|
||||
"asset": {"version": "2.0", "generator": "step-line model_pipeline"},
|
||||
"scene": 0,
|
||||
"scenes": [{"name": prop["name"], "nodes": [0]}],
|
||||
"nodes": [{"name": prop["name"], "mesh": 0}],
|
||||
"meshes": [{"name": prop["name"], "primitives": primitives}],
|
||||
"materials": materials,
|
||||
"meshes": [{
|
||||
"name": prop["name"],
|
||||
"primitives": [{
|
||||
"attributes": {"POSITION": pos_accessor,
|
||||
"NORMAL": nrm_accessor,
|
||||
"TEXCOORD_0": uv_accessor},
|
||||
"indices": idx_accessor,
|
||||
"material": 0,
|
||||
}],
|
||||
}],
|
||||
"materials": [{
|
||||
"name": "prop_palette",
|
||||
"doubleSided": False,
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorTexture": {"index": 0},
|
||||
"metallicRoughnessTexture": {"index": 1},
|
||||
"metallicFactor": 1.0,
|
||||
"roughnessFactor": 1.0,
|
||||
},
|
||||
}],
|
||||
"textures": [
|
||||
{"sampler": 0, "source": 0},
|
||||
{"sampler": 0, "source": 1},
|
||||
],
|
||||
"samplers": [{
|
||||
"magFilter": 9728, # NEAREST, so a palette cell stays a flat colour
|
||||
"minFilter": 9728,
|
||||
"wrapS": 33071, # CLAMP_TO_EDGE
|
||||
"wrapT": 33071,
|
||||
}],
|
||||
"images": [
|
||||
{"bufferView": base_view, "mimeType": "image/png"},
|
||||
{"bufferView": mr_view, "mimeType": "image/png"},
|
||||
],
|
||||
"accessors": accessors,
|
||||
"bufferViews": buffer_views,
|
||||
"buffers": [{"byteLength": len(binary)}],
|
||||
@@ -646,8 +743,25 @@ def audit_glb(path: Path) -> dict:
|
||||
issues.append("primitive references a missing accessor")
|
||||
if triangles <= 0:
|
||||
issues.append("no triangles")
|
||||
if not gltf.get("materials"):
|
||||
materials = gltf.get("materials", [])
|
||||
if not materials:
|
||||
issues.append("no materials")
|
||||
elif len(materials) != 1:
|
||||
issues.append(f"expected 1 shared material, found {len(materials)}")
|
||||
else:
|
||||
pbr = materials[0].get("pbrMetallicRoughness", {})
|
||||
if "baseColorTexture" not in pbr:
|
||||
issues.append("shared material has no baseColorTexture")
|
||||
if "metallicRoughnessTexture" not in pbr:
|
||||
issues.append("shared material has no metallicRoughnessTexture")
|
||||
if len(gltf.get("textures", [])) < 2:
|
||||
issues.append("expected colour and metallic/roughness textures")
|
||||
if len(gltf.get("images", [])) < 2:
|
||||
issues.append("expected colour and metallic/roughness atlas images")
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
if "TEXCOORD_0" not in primitive.get("attributes", {}):
|
||||
issues.append("primitive has no atlas UVs (TEXCOORD_0)")
|
||||
for buffer in gltf.get("buffers", []):
|
||||
if int(buffer.get("byteLength", 0)) > len(data):
|
||||
issues.append("buffer larger than the file")
|
||||
|
||||
Reference in new issue
Block a user