fixed xynova texture generation problem

This commit is contained in:
ykcheungab
2026-09-19 21:02:34 +08:00
parent 55bab861c3
commit 71c2a29ccb
15 changed files with 663118 additions and 63 deletions
+129
View File
@@ -0,0 +1,129 @@
"""Camera-cell clipping without UV topology assumptions (stdlib only).
Attributes are interpolated in homogeneous space before perspective division,
then as barycentric/w and 1/w in screen space. UVs are resolved only against
one evaluated loop triangle; neither island joining nor periodic wrapping occurs.
Visibility is sampled, not an exact occluder polygon arrangement. A fragment
whose seven probes (vertices, edge midpoints, centroid) all agree is emitted or
dropped as one unit; occluders smaller than the probe spacing can therefore be
missed inside such a fragment. Only mixed fragments — one straddling an
occlusion boundary — are refined, down to MIXED_SPAN_CELLS (1/4 cell) or depth
6, and an unresolved mixed leaf is rejected rather than guessed, so occluded
surface is left white. Seven probes per leaf are NOT a proof of full visibility.
"""
import math
# Screen-space tolerance for resolving a fragment that straddles an occlusion
# boundary, expressed as a fraction of one camera grid cell. The boundary is a
# curve, so cost grows as 1/tolerance; 1/4 cell already resolves the silhouette
# to a few output pixels at production raster size, while 1/128 cell (sub-pixel)
# multiplied polygon count by ~6 for no visible gain.
MIXED_SPAN_CELLS = 1 / 4
def clip_halfspace(points, distance):
if not points:
return []
result = []
prev = points[-1]
dp = distance(prev)
for cur in points:
dc = distance(cur)
if (dp >= 0) != (dc >= 0):
t = dp / (dp - dc)
result.append(tuple(a + t * (b - a) for a, b in zip(prev, cur)))
if dc >= 0:
result.append(cur)
prev, dp = cur, dc
return result
def clip_rect(points, x0, x1, y0, y1):
for axis, bound, sign in ((0, x0, 1), (0, x1, -1), (1, y0, 1), (1, y1, -1)):
points = clip_halfspace(points, lambda p: sign * (p[axis] - bound))
return points
def area(points):
return abs(sum(a[0] * b[1] - b[0] * a[1]
for a, b in zip(points, points[1:] + points[:1]))) / 2
def project_triangle(clip_vertices):
"""Clip against all six camera planes, return (u,v,1/w,b0/w,b1/w,b2/w)."""
points = [tuple(v) + tuple(float(i == j) for j in range(3))
for i, v in enumerate(clip_vertices)]
for axis in range(3):
for sign in (-1, 1):
points = clip_halfspace(points, lambda p: p[3] + sign * p[axis])
return [((p[0] / p[3] + 1) / 2, (p[1] / p[3] + 1) / 2,
1 / p[3], *(b / p[3] for b in p[4:]))
for p in points if p[3] > 1e-15]
def barycentric(point):
return tuple(v / point[2] for v in point[3:6])
def interpolate_uv(point, loop_uvs):
weights = barycentric(point)
return tuple(sum(w * uv[k] for w, uv in zip(weights, loop_uvs)) for k in range(2))
def mean(*points):
return tuple(sum(values) / len(points) for values in zip(*points))
def visible_triangles(triangle, visible, nx, ny, edge_sample_grid=5, depth=0):
a, b, c = triangle
ab, bc, ca = mean(a, b), mean(b, c), mean(c, a)
probes = (a, b, c, ab, bc, ca, mean(a, b, c))
states = [visible(barycentric(p)) for p in probes]
span = max((max(p[k] for p in triangle) - min(p[k] for p in triangle)) * n
for k, n in ((0, nx), (1, ny)))
# A uniformly visible fragment is emitted as-is. Subdividing it would only
# split an area that already shares one cell colour, multiplying polygon
# count by ~4**3 per fragment for no visual or vector benefit. Only mixed
# fragments (an occlusion boundary crossing them) are refined, because only
# there does a sub-fragment disagree with its neighbours.
if all(states):
yield triangle
return
if not any(states):
return
if depth >= 6 or span <= MIXED_SPAN_CELLS:
# Never accept an unresolved mixed leaf, even if its centroid is visible.
if all(states):
yield triangle
return
children = []
for child in ((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)):
children.extend(visible_triangles(child, visible, nx, ny, edge_sample_grid, depth + 1))
# Coalesce only the exact parent triangle after every child was accepted.
# This is not a UV hull or a cross-triangle/island join.
if children and abs(sum(area(list(t)) for t in children) - area(list(triangle))) <= area(list(triangle)) * 1e-12:
yield triangle
else:
yield from children
def cell_fragments(clip_vertices, nx, ny, visible, edge_sample_grid=5):
"""Yield (column,row,screen polygon) fragments of ONE loop triangle."""
projected = project_triangle(clip_vertices)
if len(projected) < 3 or area(projected) < 1e-18:
return
i0 = max(0, math.floor(min(p[0] for p in projected) * nx))
i1 = min(nx - 1, math.floor(max(p[0] for p in projected) * nx))
j0 = max(0, math.floor(min(p[1] for p in projected) * ny))
j1 = min(ny - 1, math.floor(max(p[1] for p in projected) * ny))
for j in range(j0, j1 + 1):
for i in range(i0, i1 + 1):
polygon = clip_rect(projected, i / nx, (i + 1) / nx, j / ny, (j + 1) / ny)
if len(polygon) < 3 or area(polygon) < 1e-18:
continue
for k in range(1, len(polygon) - 1):
tri = (polygon[0], polygon[k], polygon[k + 1])
if area(list(tri)) > 1e-18:
for fragment in visible_triangles(tri, visible, nx, ny, edge_sample_grid):
yield i, j, fragment
+73
View File
@@ -0,0 +1,73 @@
"""Blender adapter for evaluated, camera-local loop-triangle fragments."""
from patch_geometry import cell_fragments, interpolate_uv
def build_patch_geometry(obj, cam, scene, depsgraph, uv_names, nx, ny, edge_sample_grid):
from mathutils import Vector
if cam.data.type not in {'PERSP', 'ORTHO'}:
raise ValueError('Patch projection requires a perspective or orthographic camera')
camera = cam.evaluated_get(depsgraph)
evaluated = obj.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
mesh.calc_loop_triangles()
layers = {name: mesh.uv_layers.get(name) for name in uv_names}
if any(layer is None for layer in layers.values()):
raise ValueError('An evaluated mesh UV layer is missing')
render = scene.render
projection = camera.calc_matrix_camera(
depsgraph, x=render.resolution_x, y=render.resolution_y,
scale_x=render.pixel_aspect_x, scale_y=render.pixel_aspect_y)
view = camera.matrix_world.normalized().inverted()
clip_matrix = projection @ view
origin = camera.matrix_world.translation
forward = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
output = {name: {'mesh': [], 'patches': []} for name in uv_names}
ray_count = 0
for triangle in mesh.loop_triangles:
world = [evaluated.matrix_world @ mesh.vertices[i].co for i in triangle.vertices]
clips = [tuple(clip_matrix @ p.to_4d()) for p in world]
uvs = {name: [tuple(layer.data[i].uv) for i in triangle.loops]
for name, layer in layers.items()}
for name in uv_names:
output[name]['mesh'].append(uvs[name])
cache = {}
def visible(weights):
nonlocal ray_count
key = tuple(round(w, 12) for w in weights)
if key in cache:
return cache[key]
point = sum((p * w for p, w in zip(world, weights)), Vector())
depth = -(view @ point).z
ray_origin = origin if camera.data.type == 'PERSP' else point - forward * depth
direction = point - ray_origin
distance = direction.length
# Relative hit-position tolerance in world units; never rely on
# original/evaluated polygon indices (modifiers can change them).
tolerance = max(1e-7, distance * 2e-5)
if distance <= tolerance:
cache[key] = False
return False
direction.normalize()
hit, location, normal, index, hit_obj, matrix = scene.ray_cast(
depsgraph, ray_origin, direction, distance=distance + tolerance)
ray_count += 1
same_object = hit_obj is not None and getattr(hit_obj, 'original', hit_obj) == obj.original
result = bool(hit and same_object and (location - point).length <= tolerance)
cache[key] = result
return result
for i, j, fragment in cell_fragments(clips, nx, ny, visible, edge_sample_grid):
for name in uv_names:
polygon = [interpolate_uv(p, uvs[name]) for p in fragment]
output[name]['patches'].append((i, j, polygon))
print(f'Evaluated {len(mesh.loop_triangles)} loop triangles; {ray_count} visibility rays; '
f'{len(output[uv_names[0]]["patches"])} local fragments per UV target')
print(f'Visibility sampling: seven probes per fragment; uniformly probed '
f'fragments are accepted or dropped whole, mixed boundary fragments '
f'refine to 1/4 cell or depth 6 and unresolved leaves are rejected. '
f'Sub-sample occluders may be missed; hit tolerance max(1e-7, distance*2e-5).')
return output
finally:
evaluated.to_mesh_clear()
+55 -62
View File
@@ -33,6 +33,11 @@ import bpy
import numpy as np
from mathutils import Vector
# Blender -P does not consistently include the script directory on sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from patch_geometry import clip_rect
from patch_projection import build_patch_geometry
# ----------------------------------------------------------------------
# Common Utilities
# ----------------------------------------------------------------------
@@ -522,6 +527,20 @@ def generate_valid_color(neighbors, r):
color[channel] = sampled
return color
def precompute_cell_colors(nx, ny, r):
"""Assign every seeded grid cell once, independent of geometry/UV visibility."""
colors = np.zeros((ny, nx, 3), dtype=np.float32)
for j in range(ny):
for i in range(nx):
neighbors = []
if j > 0:
neighbors.append(colors[j - 1, i])
if i > 0:
neighbors.append(colors[j, i - 1])
colors[j, i] = generate_valid_color(neighbors, r) if neighbors else np.random.rand(3)
return colors
def checker_patch_color(i, j):
"""Return a black/white checker color for patch index (i, j)."""
if (i + j) % 2 == 0:
@@ -848,7 +867,7 @@ def generate_patch_texture(
vector_width_mm=164.0, vector_height_mm=160.0,
uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
uv_layer=None, image_name="PatchTexture"
uv_layer=None, image_name="PatchTexture", patch_colors=None, geometry=None
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
@@ -856,50 +875,22 @@ def generate_patch_texture(
_warn_if_page_aspect_differs(
texture_width, texture_height, vector_width_mm, vector_height_mm
)
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
if uv_layer is None:
uv_layer = obj.data.uv_layers.active
if uv_layer is None:
raise ValueError("A UV layer is required")
if patch_colors is None:
patch_colors = precompute_cell_colors(nx, ny, r)
if geometry is None:
geometry = build_patch_geometry(
obj, cam, scene, depsgraph, [uv_layer.name], nx, ny, edge_sample_grid
)[uv_layer.name]
edge_sample_grid = max(2, int(edge_sample_grid))
patch_uv_polygons = []
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
skipped_patches = 0
print(f"Raycasting {nx*ny} patches...")
for j in range(ny):
v_start = j / ny
v_end = (j+1) / ny
for i in range(nx):
u_start = i / nx
u_end = (i+1) / nx
uv_poly = sample_patch_uv_polygon(
scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end,
frame_width, frame_height,
edge_sample_grid=edge_sample_grid, uv_layer=uv_layer
)
if uv_poly is None:
patch_uv_polygons.append(None)
skipped_patches += 1
continue
patch_uv_polygons.append(uv_poly)
neighbors = []
if j > 0: neighbors.append(patch_colors[j-1, i])
if i > 0: neighbors.append(patch_colors[j, i-1])
color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
patch_colors[j, i] = color
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
def pixel_polygon(uv_polygon):
# Authored per-loop UVs are authoritative, including wide/mirrored islands.
# Clip to this output tile; do not infer a periodic seam or draw copies.
return [(u * (texture_width - 1), v * (texture_height - 1))
for u, v in clip_rect(uv_polygon, 0.0, 1.0, 0.0, 1.0)]
# Create image – start with black background
img_name = image_name
@@ -913,9 +904,8 @@ def generate_patch_texture(
# Rasterize all mesh UVs in white (visible + non-visible parts). Retain
# the exact same polygons for native SVG/PDF output.
mesh_polygons = mesh_uv_pixel_polygons(
obj, texture_width, texture_height, uv_layer=uv_layer
)
mesh_polygons = [pixel_polygon(poly) for poly in geometry['mesh']]
mesh_polygons = [poly for poly in mesh_polygons if len(poly) >= 3]
for pixel_poly in mesh_polygons:
fill_polygon(
None, pixels, texture_width, texture_height,
@@ -924,16 +914,14 @@ def generate_patch_texture(
# Draw the visible patches and retain each editable vector shape.
patch_shapes = []
idx = 0
for j in range(ny):
for i in range(nx):
uv_poly = patch_uv_polygons[idx]; idx += 1
if uv_poly is None: continue
color = patch_colors[j,i]
rgba = (color[0], color[1], color[2], 1.0)
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
patch_shapes.append((pixel_poly, rgba))
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
for i, j, uv_poly in geometry['patches']:
color = patch_colors[j, i]
rgba = (color[0], color[1], color[2], 1.0)
pixel_poly = pixel_polygon(uv_poly)
if len(pixel_poly) < 3:
continue
patch_shapes.append((pixel_poly, rgba))
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
cross_shapes = []
if uv_centre is not None:
@@ -1608,13 +1596,17 @@ def main():
if config["seed"] is not None:
np.random.seed(config["seed"])
print(f"Using colour seed {config['seed']}")
# One RNG snapshot is replayed for every target, so the same physical patch
# keeps the same colour in each UV layout (and reruns reproduce when seeded).
rng_state = np.random.get_state()
# Both targets share the very same cell table and visibility-clipped fragments.
patch_colors = precompute_cell_colors(config['nx'], config['ny'], config['color_r'])
start_time = time.time()
try:
geometry = build_patch_geometry(
obj, cam, scene, depsgraph, [name for name, _, _ in targets],
config['nx'], config['ny'], config['edge_sample_grid'])
except Exception as exc:
print(f'ERROR: patch geometry failed: {exc}', file=sys.stderr)
return 1
for uv_map, artifacts, uv_layer in targets:
np.random.set_state(rng_state)
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
print(f"--- UV map {uv_map!r}")
try:
@@ -1634,6 +1626,7 @@ def main():
edge_sample_grid=config["edge_sample_grid"],
uv_layer=uv_layer,
image_name=f"PatchTexture_{uv_map}",
patch_colors=patch_colors, geometry=geometry[uv_map],
)
except Exception as exc:
print(
+70
View File
@@ -0,0 +1,70 @@
"""Run with python -m unittest discover -s blender/scripts -p test_patch_geometry.py."""
import unittest
from patch_geometry import (area, barycentric, cell_fragments, clip_rect,
interpolate_uv, project_triangle, visible_triangles)
class GeometryTests(unittest.TestCase):
def test_perspective_correct(self):
points = project_triangle([(-1, -1, 0, 1), (2, -2, 0, 2), (-4, 4, 0, 4)])
p = tuple((a + b + c) / 3 for a, b, c in zip(*points))
self.assertAlmostEqual(sum(barycentric(p)), 1)
uv = interpolate_uv(p, [(0, 0), (1, 0), (0, 1)])
self.assertAlmostEqual(uv[0], 2 / 7)
self.assertAlmostEqual(uv[1], 1 / 7)
def test_quad_both_loop_triangles_cover_square(self):
corners = [(-1, -1, 0, 1), (1, -1, 0, 1), (1, 1, 0, 1), (-1, 1, 0, 1)]
fragments = []
for indices in ((0, 1, 2), (0, 2, 3)):
fragments.extend(cell_fragments([corners[k] for k in indices], 3, 2, lambda _: True))
self.assertAlmostEqual(sum(area(list(p)) for _, _, p in fragments), 1)
for i, j, poly in fragments:
self.assertTrue(all(i / 3 - 1e-12 <= p[0] <= (i + 1) / 3 + 1e-12 for p in poly))
self.assertTrue(all(j / 2 - 1e-12 <= p[1] <= (j + 1) / 2 + 1e-12 for p in poly))
def test_frustum_and_near_clipping(self):
poly = project_triangle([(-2, -1, -2, 1), (1, -1, 0, 1), (0, 2, 0, 1)])
self.assertGreater(len(poly), 2)
for p in poly:
self.assertTrue(0 <= p[0] <= 1 and 0 <= p[1] <= 1)
self.assertAlmostEqual(sum(barycentric(p)), 1)
self.assertTrue(all(w >= -1e-12 for w in barycentric(p)))
def test_behind_camera_is_empty(self):
self.assertEqual(project_triangle([(-1, -1, 0, -1), (1, -1, 0, -1), (0, 1, 0, -1)]), [])
def test_wide_uv_triangle_does_not_wrap(self):
poly = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
uvs = [(0.02, 0.1), (0.98, 0.1), (0.02, 0.9)]
self.assertEqual([interpolate_uv(p, uvs) for p in poly], uvs)
def test_seam_islands_remain_independent(self):
p = (0, 0, 1, 0.5, 0.25, 0.25)
left = interpolate_uv(p, [(0, 0), (.1, 0), (0, .1)])
right = interpolate_uv(p, [(.9, .9), (1, .9), (.9, 1)])
self.assertLess(left[0], .1)
self.assertGreater(right[0], .9)
def test_hidden_rejected(self):
tri = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
self.assertEqual(list(visible_triangles(tri, lambda _: False, 1, 1)), [])
def test_mixed_visibility_conservative(self):
tri = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
accepted = list(visible_triangles(tri, lambda w: w[1] < .4, 1, 1))
self.assertTrue(accepted)
self.assertTrue(all(barycentric(p)[1] < .4 for t in accepted for p in t))
self.assertLess(sum(area(list(t)) for t in accepted), .5)
def test_unresolved_depth_limit_rejected(self):
tri = project_triangle([(-1, -1, 0, 1), (1, -1, 0, 1), (-1, 1, 0, 1)])
self.assertEqual(list(visible_triangles(tri, lambda w: w[1] < .4, 1, 1, depth=10)), [])
def test_uv_tile_clips_without_repeating(self):
poly = clip_rect([(-.2, .1), (.2, .1), (.2, .9)], 0, 1, 0, 1)
self.assertTrue(all(0 <= p[0] <= .2 for p in poly))
if __name__ == '__main__':
unittest.main()