File size: 10,933 Bytes
cf8a54e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
"""Render the six canonical G-buffer views UltraTex expects from a mesh (Blender script).

Replicates the G-buffer TexVerse canonical rig (calibrated from the dataset's
position maps and poses): a perspective camera with fx = 1.5625 * width at
distance 1.67, six views [front, left, back, right, top, bottom]; the object is
centred and scaled so its longest side is 0.92 (world units in the dataset's
[-1, 1] box). Outputs, per view ``000..005``:

  <out>/bump_normal_world/*.webp  RGBA, world normals stored as (X, Y, -Z) in Blender
                                   coordinates, rgb = (n + 1) / 2  (dataset convention)
  <out>/position/*.webp            RGBA, (world + 1) / 2 in the dataset's y-up frame
  <out>/albedo/*.webp              RGBA placeholder (base colour if the mesh has one)
  <out>/uv/*.png                   16-bit UV coordinates (R, G) + alpha, for back-projection
  <out>/meta.json                  rig parameters

Usage:
  blender -b -P tools/render_gbuffer.py -- --mesh asset.glb --out /path/asset_dir [--res 2048]
"""

import argparse
import json
import math
import os
import sys

import bpy
import mathutils

RIG = [  # (camera position direction, up) in Blender z-up coordinates
    ((0, -1, 0), (0, 0, 1)),  # 000 front  (dataset +z_p)
    ((-1, 0, 0), (0, 0, 1)),  # 001 left   (dataset -x_p)
    ((0, 1, 0), (0, 0, 1)),   # 002 back
    ((1, 0, 0), (0, 0, 1)),   # 003 right
    ((0, 0, 1), (0, 1, 0)),   # 004 top    (image down = dataset +z_p = Blender -Y)
    ((0, 0, -1), (0, 1, 0)),  # 005 bottom
]
DISTANCE = 1.67
FX_OVER_WIDTH = 6400.0 / 4096.0
LONGEST_SIDE = 0.92


def parse_args():
    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    ap = argparse.ArgumentParser()
    ap.add_argument("--mesh", required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--res", type=int, default=2048)
    ap.add_argument("--engine", default="CYCLES", choices=["CYCLES", "BLENDER_EEVEE_NEXT"])
    return ap.parse_args(argv)


def clear_scene():
    bpy.ops.wm.read_factory_settings(use_empty=True)


def import_mesh(path):
    ext = os.path.splitext(path)[1].lower()
    if ext in (".glb", ".gltf"):
        bpy.ops.import_scene.gltf(filepath=path)
    elif ext == ".obj":
        bpy.ops.wm.obj_import(filepath=path)
    elif ext == ".fbx":
        bpy.ops.import_scene.fbx(filepath=path)
    elif ext == ".ply":
        bpy.ops.wm.ply_import(filepath=path)
    elif ext == ".stl":
        bpy.ops.wm.stl_import(filepath=path)
    else:
        raise SystemExit(f"unsupported mesh format {ext}")
    meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
    if not meshes:
        raise SystemExit("no mesh objects imported")
    return meshes


def normalize(meshes):
    """Centre the union bounding box at the origin and scale the longest side to LONGEST_SIDE."""
    bpy.context.view_layer.update()
    lo = mathutils.Vector((math.inf,) * 3)
    hi = mathutils.Vector((-math.inf,) * 3)
    for o in meshes:
        for corner in o.bound_box:
            p = o.matrix_world @ mathutils.Vector(corner)
            lo = mathutils.Vector(map(min, lo, p))
            hi = mathutils.Vector(map(max, hi, p))
    centre = (lo + hi) / 2
    size = max(hi - lo)
    scale = LONGEST_SIDE / size
    root = bpy.data.objects.new("ultratex_root", None)
    bpy.context.scene.collection.objects.link(root)
    for o in meshes:
        if o.parent is None or o.parent.type != "MESH":
            top = o
            while top.parent is not None:
                top = top.parent
            if top.parent is None and top is not root:
                top.parent = root
    root.location = -centre * scale
    root.scale = (scale, scale, scale)
    bpy.context.view_layer.update()
    return {"centre": list(centre), "source_longest_side": size, "scale": scale}


def make_camera(res):
    cam_data = bpy.data.cameras.new("ultratex_cam")
    cam_data.sensor_fit = "HORIZONTAL"
    cam_data.sensor_width = 36.0
    cam_data.lens = FX_OVER_WIDTH * cam_data.sensor_width  # fx = f/sensor * width
    cam_data.clip_start = 0.01
    cam_data.clip_end = 100
    cam = bpy.data.objects.new("ultratex_cam", cam_data)
    bpy.context.scene.collection.objects.link(cam)
    bpy.context.scene.camera = cam
    return cam


def aim_camera(cam, pos_dir, up):
    pos = mathutils.Vector(pos_dir) * DISTANCE
    look = -mathutils.Vector(pos_dir)
    up = mathutils.Vector(up)
    right = look.cross(up)
    # Blender camera looks down its local -Z with +Y up
    rot = mathutils.Matrix((right, up, -look)).transposed()
    cam.matrix_world = mathutils.Matrix.Translation(pos) @ rot.to_4x4()


def gbuffer_material(mode):
    """Emission-only material: 'normal' (world normal), 'position' (world position), 'uv' (uv coords)."""
    mat = bpy.data.materials.new(f"gbuffer_{mode}")
    mat.use_nodes = True
    nodes, links = mat.node_tree.nodes, mat.node_tree.links
    nodes.clear()
    out = nodes.new("ShaderNodeOutputMaterial")
    emit = nodes.new("ShaderNodeEmission")
    emit.inputs["Strength"].default_value = 1.0
    if mode == "uv":
        tex = nodes.new("ShaderNodeTexCoord")
        links.new(tex.outputs["UV"], emit.inputs["Color"])
    else:
        geo = nodes.new("ShaderNodeNewGeometry")
        src = geo.outputs["Normal"] if mode == "normal" else geo.outputs["Position"]
        # dataset convention: normal stored = ((X, Y, -Z) + 1) / 2 ; position stored = ((x_p, y_p, z_p) + 1) / 2
        # with (x_p, y_p, z_p) = (X, Z, -Y)
        sep = nodes.new("ShaderNodeSeparateXYZ")
        links.new(src, sep.inputs["Vector"])
        comb = nodes.new("ShaderNodeCombineXYZ")
        if mode == "normal":
            links.new(sep.outputs["X"], comb.inputs["X"])
            links.new(sep.outputs["Y"], comb.inputs["Y"])
            neg = nodes.new("ShaderNodeMath"); neg.operation = "MULTIPLY"; neg.inputs[1].default_value = -1.0
            links.new(sep.outputs["Z"], neg.inputs[0]); links.new(neg.outputs[0], comb.inputs["Z"])
        else:
            links.new(sep.outputs["X"], comb.inputs["X"])
            links.new(sep.outputs["Z"], comb.inputs["Y"])
            neg = nodes.new("ShaderNodeMath"); neg.operation = "MULTIPLY"; neg.inputs[1].default_value = -1.0
            links.new(sep.outputs["Y"], neg.inputs[0]); links.new(neg.outputs[0], comb.inputs["Z"])
        add = nodes.new("ShaderNodeVectorMath"); add.operation = "ADD"; add.inputs[1].default_value = (1, 1, 1)
        scale = nodes.new("ShaderNodeVectorMath"); scale.operation = "SCALE"; scale.inputs["Scale"].default_value = 0.5
        links.new(comb.outputs[0], add.inputs[0]); links.new(add.outputs[0], scale.inputs[0])
        links.new(scale.outputs[0], emit.inputs["Color"])
    links.new(emit.outputs[0], out.inputs["Surface"])
    return mat


def albedo_material_from(original):
    """Keep the mesh's own base colour (texture or constant) as flat emission, for the placeholder albedo."""
    mat = bpy.data.materials.new("gbuffer_albedo")
    mat.use_nodes = True
    nodes, links = mat.node_tree.nodes, mat.node_tree.links
    nodes.clear()
    out = nodes.new("ShaderNodeOutputMaterial")
    emit = nodes.new("ShaderNodeEmission")
    colour = (0.5, 0.5, 0.5, 1.0)
    tex_image = None
    if original is not None and original.use_nodes:
        for n in original.node_tree.nodes:
            if n.type == "BSDF_PRINCIPLED":
                base = n.inputs["Base Color"]
                colour = tuple(base.default_value)
                if base.is_linked and base.links[0].from_node.type == "TEX_IMAGE":
                    tex_image = base.links[0].from_node.image
    if tex_image is not None:
        tex = nodes.new("ShaderNodeTexImage"); tex.image = tex_image
        links.new(tex.outputs["Color"], emit.inputs["Color"])
    else:
        emit.inputs["Color"].default_value = colour
    links.new(emit.outputs[0], out.inputs["Surface"])
    return mat


def assign(meshes, mat):
    for o in meshes:
        o.data.materials.clear()
        o.data.materials.append(mat)


def setup_render(res, engine):
    scene = bpy.context.scene
    scene.render.engine = engine
    scene.render.resolution_x = scene.render.resolution_y = res
    scene.render.resolution_percentage = 100
    scene.render.film_transparent = True
    scene.render.filter_size = 0.0 if engine == "CYCLES" else 1.5
    # G-buffers are data: bypass colour management entirely (Standard would apply the sRGB OETF)
    scene.view_settings.view_transform = "Raw"
    scene.view_settings.look = "None"
    scene.display_settings.display_device = "sRGB"
    scene.render.dither_intensity = 0.0
    if engine == "CYCLES":
        scene.cycles.samples = 1
        scene.cycles.use_denoising = False
        scene.cycles.max_bounces = 0
        scene.cycles.device = "CPU"
    world = bpy.data.worlds.new("w"); scene.world = world


def render_to(path, fmt, color_depth="8", exr_float=False):
    scene = bpy.context.scene
    scene.render.image_settings.file_format = fmt
    scene.render.image_settings.color_mode = "RGBA"
    if fmt == "OPEN_EXR":
        scene.render.image_settings.color_depth = "32"
        scene.render.image_settings.exr_codec = "ZIP"
    else:
        scene.render.image_settings.color_depth = color_depth
        if fmt == "PNG":
            scene.render.image_settings.compression = 15
        if fmt == "WEBP":
            scene.render.image_settings.quality = 100
    scene.render.filepath = path
    bpy.ops.render.render(write_still=True)


def main():
    args = parse_args()
    clear_scene()
    meshes = import_mesh(args.mesh)
    original_mat = next((m for o in meshes for m in o.data.materials if m is not None), None)
    norm = normalize(meshes)
    setup_render(args.res, args.engine)
    cam = make_camera(args.res)
    os.makedirs(args.out, exist_ok=True)
    passes = [  # webp: the UltraTex dataset loaders open 00k.webp
        ("bump_normal_world", gbuffer_material("normal"), "WEBP"),
        ("position", gbuffer_material("position"), "WEBP"),
        ("albedo", albedo_material_from(original_mat), "WEBP"),
        ("uv", gbuffer_material("uv"), "PNG16"),
    ]
    for name, mat, fmt in passes:
        assign(meshes, mat)
        for i, (pos, up) in enumerate(RIG):
            aim_camera(cam, pos, up)
            ext = {"OPEN_EXR": "exr", "WEBP": "webp"}.get(fmt, "png")
            if fmt == "PNG16":
                render_to(os.path.join(args.out, name, f"{i:03d}.png"), "PNG", color_depth="16")
            else:
                render_to(os.path.join(args.out, name, f"{i:03d}.{ext}"), fmt)
    with open(os.path.join(args.out, "meta.json"), "wt") as f:
        json.dump({"mesh": os.path.abspath(args.mesh), "res": args.res, "distance": DISTANCE, "fx_over_width": FX_OVER_WIDTH,
                   "longest_side": LONGEST_SIDE, "normalize": norm, "rig": RIG}, f, indent=2)
    print("GBUFFER_OK", args.out)


if __name__ == "__main__":
    main()