testsAndMisc/python_pkg/artgate/routes/blender_scene.py
Krzysztof kuhy Rudnicki a0db432933
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
artgate: render 3D route with Standard view transform, not AgX
Blender defaults to the AgX view transform, a photoreal film emulation
that rolls off highlights and desaturates. On the flat-lit item renders
the 3D route produces, it turned gold into beige and washed out every
material -- which is why row C read as unusable in the bake-off.

Measured on an identical coin scene where only view_transform differed:
mean saturation 0.282 (AgX) vs 0.787 (Standard), a 2.8x increase.

This makes row C a fair test, not a good one: the geometry is still
low-poly primitives and ring/bone remain nearly invisible at 32px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 23:09:06 +02:00

154 lines
4.9 KiB
Python

"""Route (d) 3D: a Blender scene script, run headless.
This module is *emitted* and executed by ``blender --background --python``, so
it must not import anything from this package -- the Blender interpreter has
no path to it. It is kept here as a data file rather than a library so the
repo's linters still see it as Python.
The 3D cell exists to answer one question: does a rendered mesh, put through
the same finishing stage as the vector route, clear the pixel gates? The
render is continuous-tone by nature, so the expectation is the same as every
other continuous route -- it needs the finisher.
"""
from __future__ import annotations
from typing import Final
# Rendered square edge length before downscaling. Kept small: the finisher
# reduces to 32px anyway, and Cycles time scales with pixel count.
RENDER_SIZE: Final = 256
# Executed inside Blender's own Python. Written as a template rather than
# imported, because Blender cannot see this package.
SCENE_TEMPLATE: Final = """
import sys
import bpy
import mathutils
SUBJECT = sys.argv[-2]
OUT = sys.argv[-1]
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = {size}
scene.render.resolution_y = {size}
scene.render.film_transparent = True
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.filepath = OUT
# Blender defaults to the AgX view transform, which is built for photoreal film:
# it rolls off highlights and desaturates hard. On flat-lit item renders that
# turned gold into beige (measured mean saturation 0.28 vs 0.79 for the same
# scene), which is why this route's first pass read as washed out. Game icons
# want the raw shaded colour, so bypass tone mapping entirely.
scene.view_settings.view_transform = "Standard"
def material(name, rgba):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = rgba
bsdf.inputs["Roughness"].default_value = 0.45
return mat
GOLD = (0.84, 0.64, 0.24, 1.0)
CYAN = (0.36, 0.69, 0.75, 1.0)
RED = (0.69, 0.24, 0.24, 1.0)
BONE = (0.78, 0.73, 0.67, 1.0)
def add(obj, rgba):
obj.data.materials.append(material(obj.name, rgba))
return obj
def build_coin():
bpy.ops.mesh.primitive_cylinder_add(radius=1.1, depth=0.16, vertices=48)
add(bpy.context.object, GOLD)
bpy.ops.mesh.primitive_torus_add(major_radius=1.0, minor_radius=0.08)
add(bpy.context.object, GOLD)
def build_gem():
bpy.ops.mesh.primitive_cone_add(radius1=1.0, depth=1.2, vertices=8)
add(bpy.context.object, CYAN)
bpy.ops.mesh.primitive_cone_add(radius1=1.0, depth=0.7, vertices=8)
bpy.context.object.rotation_euler = (3.14159, 0, 0)
bpy.context.object.location = (0, 0, 0.95)
add(bpy.context.object, CYAN)
def build_ring():
bpy.ops.mesh.primitive_torus_add(major_radius=0.85, minor_radius=0.16)
add(bpy.context.object, GOLD)
bpy.ops.mesh.primitive_ico_sphere_add(radius=0.34, subdivisions=1)
bpy.context.object.location = (0, 0, 0.95)
add(bpy.context.object, CYAN)
def build_bone():
bpy.ops.mesh.primitive_cylinder_add(radius=0.22, depth=1.8, vertices=16)
add(bpy.context.object, BONE)
for z in (0.9, -0.9):
for x in (-0.22, 0.22):
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.34, segments=16)
bpy.context.object.location = (x, 0, z)
add(bpy.context.object, BONE)
def build_bomb():
bpy.ops.mesh.primitive_uv_sphere_add(radius=1.0, segments=32)
add(bpy.context.object, (0.16, 0.15, 0.17, 1.0))
bpy.ops.mesh.primitive_cylinder_add(radius=0.16, depth=0.6, vertices=12)
bpy.context.object.location = (0.25, 0, 1.15)
add(bpy.context.object, RED)
BUILDERS = {{
"coin": build_coin,
"gem": build_gem,
"ring": build_ring,
"bone": build_bone,
"bomb": build_bomb,
}}
BUILDERS[SUBJECT]()
bpy.ops.object.camera_add(location=(0, -4.2, 2.6))
camera = bpy.context.object
camera.rotation_euler = mathutils.Euler((1.02, 0, 0))
camera.data.type = "ORTHO"
camera.data.ortho_scale = 3.0
scene.camera = camera
bpy.ops.object.light_add(type="SUN", location=(2.5, -3.0, 5.0))
bpy.context.object.data.energy = 4.0
bpy.ops.object.light_add(type="AREA", location=(-3.0, -2.0, 2.0))
bpy.context.object.data.energy = 90.0
bpy.ops.render.render(write_still=True)
"""
def scene_script(size: int = RENDER_SIZE) -> str:
"""Return the Blender scene script for a given render size.
Args:
size: Square render edge length in pixels.
Returns:
Python source to hand to ``blender --python``.
"""
return SCENE_TEMPLATE.format(size=size)
# Subjects with a 3D build. Deliberately a subset: a mesh for "scroll" or
# "meat" would be modelling work, not a route comparison, and the cell's
# purpose is to test whether render->finish clears the gates at all.
SUBJECTS_3D: Final = ("coin", "gem", "ring", "bone", "bomb")