diff --git a/generate-mix.sh b/generate-mix.sh new file mode 100644 index 0000000..e179445 --- /dev/null +++ b/generate-mix.sh @@ -0,0 +1,19 @@ +python3 make_label.py assets/food_apple_01/food_apple_01_1k.blend \ + --texture assets/face_crop.png \ + --project \ + --output test-2.png \ + --azimuth 15 \ + --elevation -5 \ + --margin 0.9 \ + --transparent \ + --project-size 1.35 \ + --project-offset-y 0.05 \ + --project-offset-x -0.05 \ + --blend-mode MIX \ + --light-strength 1 \ + --ambient 0.25 \ + --blend-factor 0.75 \ + --dew 45 \ + --dew-size 0.025 \ + --dew-flatness 0.4 \ + --dew-seed 4 diff --git a/generate.sh b/generate-value.sh similarity index 64% rename from generate.sh rename to generate-value.sh index 53a59e6..129d3bc 100644 --- a/generate.sh +++ b/generate-value.sh @@ -1,7 +1,7 @@ python3 make_label.py assets/food_apple_01/food_apple_01_1k.blend \ --texture assets/face_crop.png \ --project \ - --output test.png \ + --output test-2.png \ --azimuth 15 \ --elevation -5 \ --margin 0.9 \ @@ -10,5 +10,10 @@ python3 make_label.py assets/food_apple_01/food_apple_01_1k.blend \ --project-offset-y 0.05 \ --project-offset-x -0.05 \ --blend-mode VALUE \ - --light-strength 1.5 \ - --blend-factor 1.5 + --light-strength 1 \ + --ambient 0.25 \ + --blend-factor 1 \ + --dew 45 \ + --dew-size 0.025 \ + --dew-flatness 0.4 \ + --dew-seed 4 diff --git a/render_model.py b/render_model.py index 4e1c774..b692380 100644 --- a/render_model.py +++ b/render_model.py @@ -27,6 +27,7 @@ overrides, --light-strength, --light-left/--light-up, --ambient, --unlit, import argparse import math import os +import random import shutil import sys @@ -94,6 +95,12 @@ def parse_args(): help="degrees the light is raised above the camera's view axis (negative = down)") p.add_argument("--ambient", type=float, default=0.1, help="uniform fill light strength (0 = none)") + p.add_argument("--dew", type=int, default=0, help="number of dew drops to scatter (0 = none)") + p.add_argument("--dew-size", type=float, default=0.03, + help="largest drop radius as a fraction of the model's size") + p.add_argument("--dew-flatness", type=float, default=0.55, + help="drop height relative to its radius (1 = round bead)") + p.add_argument("--dew-seed", type=int, default=0, help="random seed for drop placement") p.add_argument("--unlit", action="store_true", help="skip lighting/roughness/normal; emit the blended colour as-is") p.add_argument("--resolution", type=int, default=1024, help="square output size in px") @@ -172,8 +179,8 @@ def uv_islands(bm, uv_layer): return islands -def mark_stem(meshes, max_width): - """Store a 'projection_keep' face-corner attribute (0 on the stem) and return non-stem vertices. +def find_stem_faces(meshes, max_width): + """Polygon indices of the stem, per mesh name. The stem is any UV island in the top half of the model whose horizontal extent is under max_width x the model's width. @@ -181,7 +188,7 @@ def mark_stem(meshes, max_width): center, _, _ = bounds(meshes) corners = [o.matrix_world @ Vector(c) for o in meshes for c in o.bound_box] model_width = max(max(c[i] for c in corners) - min(c[i] for c in corners) for i in (0, 1)) - body_verts = [] + stems = {} for obj in meshes: mesh, mw = obj.data, obj.matrix_world stem_faces = set() @@ -194,6 +201,17 @@ def mark_stem(meshes, max_width): if spread < max_width * model_width and min(p.z for p in pts) > center.z: stem_faces.update(f.index for f in island) bm.free() + stems[obj.name] = stem_faces + return stems + + +def mark_stem(meshes, max_width): + """Store a 'projection_keep' face-corner attribute (0 on the stem) and return non-stem vertices.""" + stems = find_stem_faces(meshes, max_width) + body_verts = [] + for obj in meshes: + mesh, mw = obj.data, obj.matrix_world + stem_faces = stems[obj.name] print(f"{obj.name}: {len(stem_faces)} stem faces kept as-is") keep = mesh.attributes.get("projection_keep") or \ @@ -380,6 +398,86 @@ def make_material(args, maps, texture): return mat +def add_dew(scene, meshes, args): + """Scatter flattened water droplets over the skin (not the stem or the underside).""" + rng = random.Random(args.dew_seed) + _, _, extent = bounds(meshes) + stems = find_stem_faces(meshes, args.stem_max_width) + + # Candidate triangles, weighted by area, so droplets are spread evenly over the surface. + tris, weights = [], [] + for obj in meshes: + mesh, mw = obj.data, obj.matrix_world + normal_mw = mw.to_3x3().inverted().transposed() + mesh.calc_loop_triangles() + for tri in mesh.loop_triangles: + if tri.polygon_index in stems[obj.name]: + continue + normal = (normal_mw @ tri.normal).normalized() + if normal.z < -0.3: # underside + continue + pts = [mw @ mesh.vertices[v].co for v in tri.vertices] + tris.append((pts, normal)) + weights.append((pts[1] - pts[0]).cross(pts[2] - pts[0]).length / 2) + if not tris: + return + + # Clear water, darkened towards the edges so each drop's outline reads. + water = bpy.data.materials.new("water") + water.use_nodes = True + nodes, links = water.node_tree.nodes, water.node_tree.links + bsdf = nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = (1, 1, 1, 1) + bsdf.inputs["Roughness"].default_value = 0.02 + bsdf.inputs["IOR"].default_value = 1.33 + bsdf.inputs["Transmission Weight"].default_value = 1.0 + rim = nodes.new("ShaderNodeBsdfGlossy") + rim.inputs["Color"].default_value = (0.05, 0.05, 0.05, 1) + rim.inputs["Roughness"].default_value = 0.02 + edge = nodes.new("ShaderNodeLayerWeight") + edge.inputs["Blend"].default_value = 0.35 + mix = nodes.new("ShaderNodeMixShader") + links.new(edge.outputs["Facing"], mix.inputs["Fac"]) + links.new(bsdf.outputs["BSDF"], mix.inputs[1]) + links.new(rim.outputs["BSDF"], mix.inputs[2]) + links.new(mix.outputs["Shader"], nodes["Material Output"].inputs["Surface"]) + + # Light refracting through the drops (caustics) only adds fireflies here. + scene.cycles.caustics_refractive = False + scene.cycles.caustics_reflective = False + scene.cycles.blur_glossy = 1.0 + + bm = bmesh.new() + bmesh.ops.create_uvsphere(bm, u_segments=24, v_segments=12, radius=1.0) + for face in bm.faces: + face.smooth = True + drop_mesh = bpy.data.meshes.new("dew_drop") + bm.to_mesh(drop_mesh) + bm.free() + drop_mesh.materials.append(water) + + collection = bpy.data.collections.new("dew") + scene.collection.children.link(collection) + for i, (pts, normal) in enumerate(rng.choices(tris, weights, k=args.dew)): + a, b = rng.random(), rng.random() + if a + b > 1: + a, b = 1 - a, 1 - b + point = pts[0] + (pts[1] - pts[0]) * a + (pts[2] - pts[0]) * b + # Log-uniform size between dew-size/2.5 and dew-size, as a fraction of the model. + r = extent * args.dew_size * math.exp(rng.uniform(math.log(0.4), 0)) + height = r * args.dew_flatness + drop = bpy.data.objects.new(f"dew_{i}", drop_mesh) + drop.scale = (r, r, height) + drop.rotation_euler = normal.to_track_quat("Z", "Y").to_euler() + # Sink the drop so roughly its top 70% sits proud of the skin. + drop.location = point + normal * height * 0.4 + # Light reaching the skin through a drop is a caustic that renders dark and + # noisy; letting it pass straight through keeps the skin under the drop lit. + drop.visible_shadow = False + collection.objects.link(drop) + print(f"Added {args.dew} dew drops") + + def find_maps(args, meshes): original = next((m for o in meshes for m in o.data.materials if m), None) maps = {} @@ -500,6 +598,10 @@ def main(): center, dist, radius = frame_camera(scene, meshes, args.azimuth, args.elevation, args.margin) if not args.unlit: add_light_behind_camera(scene, center, dist, radius, args) + if args.dew: + add_dew(scene, meshes, args) + elif args.dew: + print("--dew is ignored with --unlit") setup_render(scene, args) bpy.ops.render.render(write_still=True) diff --git a/renders/dew_test.png b/renders/dew_test.png new file mode 100644 index 0000000..f0f74a0 Binary files /dev/null and b/renders/dew_test.png differ diff --git a/test-2.png b/test-2.png new file mode 100644 index 0000000..950a856 Binary files /dev/null and b/test-2.png differ diff --git a/test.png b/test.png index bdf661d..23e4a80 100644 Binary files a/test.png and b/test.png differ