scenario-blender-uv-baking
Use when UV unwrapping, placing seams, straightening or packing UVs, setting texel density, laying out UDIMs or mirrored UVs, or baking normal, AO, curvature, ID or displacement maps from a sculpt or high poly onto a low poly or retopo in Blender, including Multires bakes. Also w
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-uv-baking
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
git clone https://github.com/scenario-labs/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
UV unwrapping and baking
Expert-level UVs put seams where the texturing method can afford them, set texel density before texture size, and pack with pixel-true padding. Expert bakes measure their projection distances instead of guessing, and are judged in pixels and under a raking light, never eyeballed once. Every stage below has a measurable gate that scripts/bx_uvbake.py computes. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.
REQUIRED BACKGROUND: scenario-blender-expert (execution channel, review loop, 5.2 API changes).
Stance (the expert delta)
- Seams follow the texturing method. Kaspar (2021, 2D painting): as few as possible, hidden under hair, clothes and inner limbs. Blender Studio guide (2024, same author): more seams rather than fewer when you paint in 3D or bake, because stretch costs more. Game bakes add a hard rule: every hard edge is a seam, or the normal map cracks (Lampel, Gambrell, On Mars 3D, Polycount).
- Only closed shells and long tubes need extra cuts. Morren: anything closed on itself needs one cut. Gambrell: cut every taped-paper ring once, hidden. But an island with holes (a face with eye holes, a washer) flattens fine.
cut_to_diskstest-unwraps it and cuts only if it stretches [added; cutting everything folded the sheep's big islands]. - Unwrap method is a measurement, and 5.2 defaults to CONFORMAL. Always pass
method=. On Suzanne, Minimum Stretch gives the most even density (spread 1.38), Angle Based the best angles, and Conformal fails by area (5.89). On flat hard-surface islands all three converge.unwrap(method="BEST")picks per island and rejects islands that fold over themselves. - Density first, texture size second (Huge Menace). Targets: FPS 10.24 px/cm, weapon 20.48, third person 5.12, top-down 2.56. Priority follows the camera: face, hands and front up; soles, inner mouth and back down (SpeedChar, Kaspar). Match texture sets by scaling the denser set down, never the other up (Gambrell).
- Straighten, then equalize, then pack without tilting (Lampel): Follow Active Quads from a truly rectangular quad, pin, re-unwrap the rest, pack with CARDINAL. Padding is a pixel number, res/128 (16 px at 2K), passed as FRACTION margin = gap/(2*res).
- Measure the projection, never guess extrusion, and do not trust one global value. Ryan King's 0.01 then 0.02 was scene-specific. SpeedChar swept six values by hand, and the value that fixed the face broke the armpit ("when we fix something somewhere, some other problems occur", E6QMhGuhyoc [00:26:23]). Rays start on the low, and
max_ray_distancecounts from the extruded start. The adaptive bake keeps, per texel, the smallest extrusion that hits a front face. On the sheep it cut inverted texels from 7.5% to 1.2% [verified]. - Unique UVs wherever baked lighting differs (SpeedChar: an AO ghost means shared texels). Stacked or mirrored copies move exactly one tile out of 0-1 before the bake (Polycount), because inside 0-1 the last island baked wins.
- A bake is judged in pixels and under light. Blue < 0.5 means rays hit the far side. Exactly flat means rays missed. All-white AO means the distance is too small. Inverted pixels that do not move when extrusion changes point to a geometry problem, not a setting [verified on the sheep]. Multires bakes cast no rays: on the same surface they gave 0.09% inverted texels.
Establish first
| Input | Changes | Default when the brief is silent |
|---|---|---|
| Target | seams, symmetry, UDIM vs sets, triangulation | game engine, one 0-1 set per material |
| Texturing method | seam count and visibility | 3D painting/Substance: seams may show at the back |
| Camera | density and priorities | third person 512 px/m, closest view defines tiles |
| Texture budget | resolution, sets, UDIM count | 2048 per set; texture_size_for decides |
| Deformation | seams off joints, tangent-space normals | character: tangent space, unique UVs |
| High source | Selected to Active vs Multires | separate sculpt: Selected to Active |
| Engine convention | normal green channel | OpenGL (Blender, Unity, glTF); directx=True for Unreal |
Workflow
- Pre-flight. Apply scale (On Mars 3D: it "fixes the vast majority" of problems). Clear non-manifold geometry, doubles and back faces (SpeedChar). Decide on Mirror and Solidify modifiers. If UVs exist, compare
island_topologywithuv_qaislands: seam flags in files are often leftover retopology marks, so runseams_from_uv_islands(Lampel) before re-unwrapping. GATE: scale 1,bx_auditclean, topology table read. - Seams. Hard surface:
seams_from_sharp(obj, 30, mark_sharp=True). Organic: build part labels (head, ears, limbs, torso front and back, soles, inner mouth and eyelids per the Kaspar and Morren seam maps inreferences/expert-notes.md), thenseams_from_groups(obj, labels). Specific cuts:seam_path(obj, v_a, v_b, view_dirs). Thencut_to_disks(obj, view_dirs=[camera]). GATE:must_cutempty; a checker render shows no seams across hero areas that the brief cannot afford. - Unwrap.
unwrap(obj, method="BEST"), or the method the island type calls for. GATE:unsolved_faces0 (the operator returns FINISHED even when it fails),folding_islandsempty (elseseams_from_groups(..., faces=folding_faces)and unwrap again), angle p95 at most 15 to 20 deg. - Straighten grid-like islands (hard surface, garments, strands):
straighten(obj)before any scaling. GATE: straight stripes in the checker. - Texel density.
normalize_texel_density(obj, target, res, weights), thentexture_size_for(obj, target). GATE: island spread within 10-20%, or the intended priority ratios. - Pack.
pack(obj, tex_res=res), withscale=Falsefor a strict density andallow_any=Truefor organic islands. UDIM: pack each region, thenmove_to_tile. GATE:uv_qa: 0 overlaps, gap at least res/128, border at least gap/2, coverage at least 0.70 or an explained exception. - Look.
uv_layout_image,checker_review,distortion_review(metric="angle"). Judge withreferences/critique.md. - Bake prep.
prepare_low(low, triangulate=game, sharp_from_uv_islands=hard_surface). Check the silhouette against the high (bx_audit.audit(low, high=...)). Explode interpenetrating or moving parts (explode, one axis). Runmeasure_projection:miss_pctunder 2 andwrong_part_pctunder 1, else use a cage, explode, or bake per pair. - Bake.
bake_high_to_low(low, highs, out, res=512)first, then the final resolution. It uses adaptive extrusion by default and hides every other object from render. Multires:bake_multires. Extras:bake_id,curvature_from_normal. GATE:sanity[...]["problems"]empty (inverted texels under 2%, notes from 0.2%), 16-bit PNG. If inverted texels remain, runmap_problem_reviewto see where. Ifsweep_projectionshows they do not move with extrusion, fix the geometry (fit, holes, thin parts), not the numbers. - Review and hook up.
bake_review(low, highs, paths, out): the low with maps must read like the high under the raking light. Thenhookup_maps. Report measured numbers and what was not verified.
Numbers
| Value | Relative to | Source |
|---|---|---|
| 10.24 / 20.48 / 5.12 / 2.56 px/cm | FPS world / FPS weapon / third person / top-down | Huge Menace [00:12:28] |
| 10-20% density mismatch invisible | between islands or sets | Gambrell [01:36:16] |
| coverage above 70%, 75% studio minimum | one 0-1 tile | Gambrell [01:31:56], Huge Menace [00:16:27] |
| island gap res/128 (8/16/32 px at 1K/2K/4K) | texture resolution | Lampel [00:38:01], On Mars 3D [00:11:18] |
| FRACTION margin m: gap 2m, border m | Blender pack | verified |
| bake margin res/128, 16 px at 2K | texture resolution | On Mars 3D, digest |
| cylinder sides: 6 min, 8 recommended | low poly silhouette | On Mars 3D [00:03:48] |
| samples: 16 for normals, 128 for AO, 1 only while iterating | antialiasing and noise | Gambrell/Marmoset 16, SpeedChar 4x4, verified |
| test bake 512, final 2048 | iteration loop | SpeedChar [00:59:09] |
| AO distance about 0.2 x bbox diagonal (default 10 units) | asset size | [added], verified effect |
| angle error p95 at most 15 deg organic, 3 deg flat | per corner | [added] from 5.2.1 measurements |
| Blender packer on an organic character: 55-62% | 16 px gap at 2K | measured on the sheep (several runs) |
| inverted texels: single pass 7.3-7.8%, adaptive 2.2% (sculpt) / 1.2% (consistent high) | selected to active, sheep | measured |
| 2048 bake, 186k-face high: one pass normal + AO 4.4 s, adaptive 25 s | Metal GPU, headless | measured |
Quality gates
Code: q = uv_qa(obj, res), then uv_verdict(q, "game" or "film", priorities=...). unwrap()["unsolved_faces"] and ["folding_islands"]. measure_projection(). bake_high_to_low()["sanity"] (inverted, exact-flat, non-unit, AO black and white). Cross-check overlaps with bpy.ops.uv.select_overlap() when in doubt; it agreed with the raster on the tests.
Visual: layout PNG (islands, magenta overlaps), color-grid checker from front, side, three-quarter and the production camera (squares square, letters upright, seams hidden), angle and density heat maps, bake_review sheet (high, low, low + maps), map_problem_review when sanity flags pixels.
Common mistakes
| Mistake | Looks like | Fix |
|---|---|---|
uv.unwrap() without method |
Conformal area distortion | pass method= or use unwrap(method="BEST") |
| Trusting FINISHED | closed island keeps its old UVs, repacked | zero the UVs first, check for zero-area faces (unwrap() does it) |
| Re-unwrapping without Seams from Islands | layout lost; retopology marks used as seams | seams_from_uv_islands first |
| Cutting every island to a disk | slits, folded islands | measured cut_to_disks (default) |
| Pack rotation ANY after straightening | tilted grids, jaggy lines | CARDINAL |
| Blender margin 0.005 | padding depends on island scale | FRACTION, gap/(2*res) |
| Target node active but not selected | bake returns CANCELED, no error | select and activate; assert FINISHED |
| Low carries the sculpt as Multires | almost flat normal map | render levels 0 for the bake (bake_high_to_low does it) |
| max_ray shorter than extrusion | 90% exactly flat map | ray = extrusion + inward depth |
| One global extrusion on thin, close parts (ears on a head) | inverted patches, neighbors baked in | adaptive bake (default), explode, or per-pair bakes |
| Other objects render-visible during AO | black AO | hide them from render; the active low never occludes |
| Default AO distance 10 | AO all dark or flat | about 0.2 x asset diagonal |
| Stacked UVs baked in 0-1 | one side's detail on both | unique UVs or offset_stacked |
| Hard edge inside an island | crack lines | seam it, or prepare_low(sharp_from_uv_islands=True) |
| Editing UVs after the bake | seams, tangent mismatch | never; re-bake |
Blender 5.2 notes
uv.unwrapdefaults to CONFORMAL. On failure it keeps the island's old UVs and repacks them. It hasuse_weights/weight_group(default group "uv_importance"): importance weights with Minimum Stretch. A weighted top half went from 31% to 74% of UV area [verified].- Sync select is on by default (5.0).
BMLoopUVonly hasuvandpin_uv. Ame.uv_layers[...]reference goes stale after an Edit Mode toggle, so keep the name. pack_islands(udim_source="CLOSEST_UDIM")headless packed a region sitting in 1002 back into 1001: pack first, then move.uv.move_on_axisneeds a UV editor.- Bake: the target must be selected AND active (5.0). Operator defaults differ from scene defaults (
use_clearFalse vs True, EXTEND vs ADJACENT_FACES): pass everything. Selected to Active uses the low's render evaluation (Multires render_levels, shape keys). Multires settings live inscene.render.bake.use_multireswithobject.bake_image(). A float image saved as PNG is 16-bit. Normal Map nodeconventionOPENGL/DIRECTX (5.1). UDIM tiles needimage.tile_add(fill=True). - Auto Smooth is gone (4.1): smooth shading plus sharp edges; Triangulate "Keep Normals" is
keep_custom_normals.
References
references/expert-notes.md: judgment by expert with timestamps, disagreements and deciding conditions, my 5.2.1 measurements. Load when choosing seams, density or a bake strategy.references/procedures.md: tested pipelines (hard-surface, character, film/UDIM, Multires, bake-fix loop) and raw bpy snippets executed by the tests. Load before writing code.references/critique.md: rubric for seams, stretch, density, packing, bake setup and baked maps, with proxies and severity. Load at every gate.references/sources.md: videos, credentials, URLs, best timestamps, web references.scripts/bx_uvbake.py: the module (docstrings list every function).
Files (skills)
-
references
-
critique.md 17.1 KB
# Critique rubric: judging UVs and bakes like the experts Use after every stage. Each line: what the expert looks at, the measurable proxy (`bx_uvbake`), and severity. Severity: **block** = fix before the next stage; **fix** = fix before delivery; **note** = mention, accept if the brief allows. Calibrate: a stage passes with notes; do not dramatize a note into a block. ## A. Seams | Look at | How the expert sees it | Proxy | Pass | Severity | | ------------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------ | | Seam visibility | Checker or stripes from the main camera; seams under hair, clothes, inner limbs, back (Kaspar, Morren) | `checker_review` views from the production camera; `cut_to_disks(view_dirs=...)` places cuts on faces facing away | No island border across the face, front torso or other hero area unless the brief uses 3D painting/triplanar | fix (film/2D paint), note (3D paint) | | Hard edges vs seams (baked assets) | Crack lines along edges after the bake (On Mars 3D, Lampel) | `uv_qa()["sharp_edges_not_on_seams"]` | 0 | block for game bakes | | Closed shells, handles | Unwrap fails or collapses | `island_topology()["must_cut"]`; `unwrap()["unsolved_faces"]` | empty / 0 | block | | Long tubes left uncut | Squashed, bent strips (Gambrell ring rule) | island density spread > 2 or angle p95 > 20 in the test unwrap (`cut_to_disks` does this) | cut once, hidden | fix | | Over-cutting | Slits, confetti islands, seams where nothing forced them | island count vs part count; islands with < 10 faces; slivers in `uv_layout_image` | Every seam has a reason: hidden, hard edge, tube, fold | note | | Symmetric seam placement (characters) | Left and right match (Kaspar Select Mirror) | compare seam edge sets mirrored in X | Matches, unless the design is asymmetric | note | ## B. Unwrap and stretch | Look at | Proxy | Pass | Severity | | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -------------------- | | Angle stretch (skew) must stay out of yellow (Kaspar) | `angle_err_p95_deg` | <= 15 deg organic, <= 3 deg flat hard-surface [added from 5.2.1 measurements] | fix above 20 | | Area distribution even inside an island (checker squares same size) | `td_face_spread_p95_p05`; `distortion_review(metric="density")` | <= 2.0 organic, <= 1.25 hard surface | fix | | Folds (no flipped faces but the island covers itself) | `unwrap()["folding_islands"]`, `self_overlapping_islands` | none | block | | Flipped UVs | `flipped_faces` | 0 (unless an intentional mirrored stack) | block | | Straight grids straight (hard surface, garments, strands) | axis-aligned share of island edges; stripes in the checker stay straight (Lampel) | >= 0.95 on islands meant to be grids | fix | | Orientation consistent (color grid letters upright) | checker render | letters read the same way on neighboring islands | note (2D paint: fix) | ## C. Texel density | Look at | Proxy | Pass | Severity | | ------------------------------------- | --------------------------------- | ---------------------------------------------------------------------- | -------- | | Density vs target | `texel_density()["px_per_m"]` | within 10% of target (strict) or above (soft) (Huge Menace) | fix | | Uniformity between islands | `td_island_spread_p95_p05` | <= 1.25 (Gambrell: 10-20% invisible), or the intended priority ratios | fix | | Priorities follow the camera | per-island TD vs the priority map | face/hands/front >= body >= back/soles/inner mouth (SpeedChar, Kaspar) | note | | Density across texture sets / objects | median TD per set | within 10%, higher set scaled down (Gambrell) | fix | | Consistent visual fidelity | render with real textures | texture content scale and sharpness match (Huge Menace [00:05:41]) | note | ## D. Packing | Look at | Proxy | Pass | Severity | | ------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | Overlaps inside the baked tile | `overlap_px_pct`, `overlapping_island_pairs`; cross-check `bpy.ops.uv.select_overlap()` | 0 (< 0.05% slit-tip slivers tolerated) | block before baking | | Padding | `min_gap_px`, `per_tile[...]["border_px"]` | gap >= res/128, border >= gap/2 | fix | | Coverage | `per_tile[1001]["coverage"]` | >= 0.70, target 0.75 (Gambrell, Huge Menace); Blender's packer measured 0.55-0.62 on an organic character | note below 0.70 with the reason; fix below 0.50 | | Islands inside the tile / tiles | `tiles`, `faces_crossing_tiles` | game: [1001] only; UDIM: no face crosses a tile (Hernandez) | block | | Straightened islands not tilted | pack result `rotate` | CARDINAL or AXIS_ALIGNED | fix | | Mirror-stacked layout going to a bake | `mirror_stacked_hint` | offset +1 tile or unique UVs | block for AO | ## E. Bake setup (before pressing bake) | Look at | Proxy | Pass | Severity | | --------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------- | | Silhouette of low matches high (On Mars 3D, Abbitt) | `bx_audit.audit(low, high=...)` fidelity; `bake_review` silhouettes | no visible profile mismatch | fix | | Low shading | `flat_shaded_share` | smooth, hard edges only on seams | block | | Low evaluated at the base | Multires/Subsurf render levels on the low | base level (bake uses render levels) | block | | Projection | `measure_projection`: `miss_pct`, `wrong_part_pct` | miss < 2%, wrong part < 1%; else cage, explode or per-pair bakes | fix | | Moving or intersecting parts | parts within the ray distance of another part | exploded along one axis (SpeedChar) or baked per pair | fix | | Target image | Non-Color, float for normal/displacement, node selected AND active | `bake_high_to_low` does it; raw code asserts FINISHED | block | | Game low triangulated | Triangulate modifier or triangulated mesh | present if the engine gets triangles (Kaspar #3, Polycount) | fix (games) | ## F. Baked maps | Artifact (SpeedChar, On Mars 3D, Polycount) | Signature in `bake_sanity` / image | Cause | Fix | Severity | | ----------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------- | | Black or wildly colored patches | `inverted_pct` > 2 problem, 0.2-2 note (blue < 0.5); `map_problem_review` shows where | ray start inside or behind a part of the high; or low and high differ there | adaptive bake (default); if a sweep does not move it, fix geometry (fit, holes, thin parts) | block above 2% on visible areas | | Flat areas where the high has detail | `flat_exact_pct` high, map std tiny | max ray too short (counted from the extruded start), high not selected, low not at base level | recompute, check levels/selection | block | | Detail from the wrong part (fingers, neighbors) | speckles near gaps; `wrong_part_pct` | ray too long, parts too close | shorter ray, cage shrunk locally, explode, per-pair bakes | fix | | Crack lines along edges | discontinuity on hard edges | hard edge without seam | seam it or smooth it | block | | Big gradients across flat faces | low-frequency ramps in the image | fully smooth low on a boxy asset | hard edges + seams at 90 deg corners | fix (games) | | Wavy lines on cylinders | sinusoidal bands | too few sides, averaged normals | 8+ sides, support edges, cage | fix | | Straight line through a face | line along a quad diagonal | triangulation differs between bake and engine | triangulate before baking, ship that triangulation | fix | | One side's detail/AO on both sides | AO ghost with no occluder | stacked or mirrored UVs inside 0-1 | unique UVs or offset +1 tile | block for AO | | Jagged fine detail | aliasing | samples 1 | 16 samples (Marmoset 16, SpeedChar 4x4) | fix | | AO pure white / flat | `white_pct` > 85 | world AO distance too small | distance ~ asset scale | fix | | AO black | `black_pct` > 10 | render-visible object enclosing the surface | hide it from render (bake_high_to_low does) | block | | Bumps lit from the wrong side in engine | inverted green | OpenGL vs DirectX | `directx=True` for Unreal | block | | Seams visible in render | color jump at UV borders | too little padding, mip bleed, tangent mismatch, UVs edited after baking | margin 16 px at 2K, same tangent basis, never edit UVs after baking | fix | ## G. The visual pass (always, before saying done) 1. `uv_layout_image(obj, path, color="island")` and `color="density"`: islands spaced, no magenta, sensible sizes. 2. `checker_review([obj], out)`: squares square and even, letters readable, seams where the brief allows. 3. `distortion_review(obj, out, metric="angle")`: red only where unavoidable (corners, poles). 4. `bake_review(low, [high], maps, out)`: row 3 (low + maps) should read like row 1 (high) under the raking light: same forms, no dark islands, no cracks, no seams, silhouette unchanged (the bake cannot fix silhouette). 5. Open the normal and AO PNGs themselves: no black islands, no noise speckles, no hard color jumps inside islands. Report what was measured, what was looked at, and what was not verified (engine import, compression). -
expert-notes.md 22.7 KB
# Expert notes: UV unwrapping and baking The judgment behind SKILL.md, by expert, with source and timestamp. [added] = not stated by an expert (my measurement or heuristic). [verified] = run in Blender 5.2.1 (test scripts in `tests/code/blender-uv-baking/`, distiller probes in `archive/tests/uv_unwrapping/` and `archive/tests/uv_snow_and_baking/`). ## Where experts disagree, and what decides | Topic | Position A | Position B | Deciding condition | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | How many seams | Kaspar 2021: "as few seams as possible, hide them" (_LI28r-Nk5g [00:34:00]; 8wmhYarIyjw [01:37:04]) | Blender Studio guide 2024, same author: "more seams rather than less, stretching and uneven texel density is much more of an issue" | 2D painting (Krita, image editor) makes every seam cost paint-over time: fewer. 3D painting, triplanar and baking: stretch costs more than seams (On Mars 3D dm3bBpZVmnE [00:26:18]). Games add mandatory seams on hard edges, and every seam duplicates vertices (Polycount). | | Unwrap method | Lampel: Angle Based "almost always better", the manual's Conformal advice "not quite accurate" (KCKro3eGmLM [00:16:28]) | On Mars 3D and Gambrell: Conformal for hard surface (dm3bBpZVmnE [00:05:38]; HDURGTLNu2Q [01:03:04]); Morren: Minimum Stretch to finish heads (JwkgVckqGw4 [00:45:33]) | Island shape. Flat islands bounded by sharp-edge seams converge in every method; curved islands need Angle Based (angles) or Minimum Stretch (area). Measured on Suzanne: spread 2.08 / 5.89 / 1.38, angle error 9.6 / 10.4 / 12.9 deg for Angle Based / Conformal / Minimum Stretch [verified]. Conformal fails by area, not angle. `unwrap(method="BEST")` picks per island. | | Symmetric UVs | Film: symmetric halves (Mirror U + Flip UDIM) so textures can be mirrored in 2D (_LI28r-Nk5g [00:45:36]; 8wmhYarIyjw [01:57:37]) | Games: ignore symmetry, use the space; stack mirrored parts (8wmhYarIyjw [01:58:10]; Gambrell HDURGTLNu2Q [01:10:43]) | 2D editability vs unique texel budget. Anything with asymmetric baked AO needs unique UVs (SpeedChar qFMwPSv5KwA [00:04:29], E6QMhGuhyoc [00:04:56]). Stacked copies are offset exactly 1 tile before baking (Polycount). | | UDIM or texture sets | Film creature: UDIMs in one material, per-region resolution (Hernandez 55sGQLX7iho [00:06:33]; Kaspar 3 tiles [00:14:44]) | Game truck: split materials into texture sets (Gambrell [01:25:24]) | Target renderer: engines consume one 0-1 set per material [added]; offline renderers read UDIMs. | | Texel density | Uniform: a sharp part next to a blurry one "doesn't make any sense" (Gambrell [01:22:40]) | Deliberate priorities: eyes, head, hands denser than shirt (Kaspar xZsrMfCI1Wg [01:28:32]; SpeedChar [00:11:05]) | Hero close-up areas vs tiled/procedural surfaces; measure per priority group, not globally. | | Margin | Lampel: res/128 for games, less for film (KCKro3eGmLM [00:38:01]) | On Mars 3D: 0.005-0.01 in Blender units (dm3bBpZVmnE [00:06:55]); 16 px at 2K (C_RqdNbYOjE [00:11:18]) | Different units. Blender's default Scaled margin is relative to island scale. For pixel-true padding use FRACTION: gap between islands = 2 x margin, border = margin [verified]. 16 px at 2K = res/128. | | Hard edges on the game low | All smooth ("Auto Smooth 180", SpeedChar qFMwPSv5KwA [00:48:50]); a fully smooth low "looks okay" (On Mars 3D [00:08:36]) | Hard edges with seams keep the normal map flat enough to survive compression (On Mars 3D [00:09:43]) | Compression and vertex budget. Hard edges only where there is a seam; film meshes are subdivided and smooth anyway. | | Extrusion vs cage | Extrusion only, 0.01 then 0.02 (Ryan King Zxl38NpPWdo [00:09:49]) | Custom cage when one distance cannot fit (On Mars 3D [00:13:44]; Polycount) | Tight gaps (fingers, flat faces missed): cage. One distance fits: measured extrusion. | | Bake precision | 32-bit float for normal maps, "a lot higher detail" (Ryan King [00:07:33]) | 32-bit float "good for films, not for games" (Abbitt LvFilHJfinU [00:18:25]) | Bake in float (no banding), deliver 16-bit PNG (Polycount), 8-bit only at export. A float image saved as PNG is written 16-bit [verified]. | | Separate high vs Multires | Selected to Active from a separate sculpt (Ryan King) | Multires on the game mesh, bake from Multires, "much quicker" (Abbitt [00:21:43]) | Multires needs the low to be the Multires base (same topology). Retopologized sculpts, kitbashed highs, AI meshes: Selected to Active. | | Ray distance | One global value, accept small artifacts (SpeedChar E6QMhGuhyoc [00:27:30]) | Region texture sets baked with their own distance and composited (SpeedChar [00:28:04], rejected as hassle) | Cheap in code: bake regions with their own measured distances into one image with `use_clear` only on the first pass [verified accumulation, distiller probe_pairs.py]. | ## Julien Kaspar (Blender Studio), Snow UV lives and the 2024 UV guide - Seams first for invisibility: under clothing, behind the hairline, inside lips, on the inner side of limbs; legs on the inner side are "the best place to hide seams of all" (_LI28r-Nk5g [00:10:01], [00:40:27]). - Back-of-head seam stops where a shorter haircut would still hide it, to keep alternative hairstyles possible [00:13:04]. - Separate the sides of anything that wraps around (ears front and back), like a cube [00:36:45]. - Area stretch is the working view (distribution); angle stretch is the hard limit that must stay out of yellow [00:16:59], xZsrMfCI1Wg [00:38:33]. The overlay is relative and not UDIM aware: read uniformity per tile, not colors [01:15:03]. - Pins are the real relaxation tool: pin a few points, move them, let the solver re-flow; the UV relax brush is "just 2D", "not relaxing, it's smoothing" [00:50:33], [01:43:38], 8wmhYarIyjw [01:59:42]. Two pins per island lock position, rotation and scale through re-unwraps [01:40:04]. - Close eyes and mouth in UV space: unwrap with a closed-eye shape key, or pin the outer and inner eyelid loops and pull the inner ones together [00:51:44], [00:56:16]. - Live Unwrap "freaking out" means a missing seam, flipped faces, or overlapping pinned points (xZsrMfCI1Wg [00:17:28], [01:09:36]). - Clothing UVs follow the sewing pattern and are straightened: "you don't have a shirt that has fabric patterns that move in a curve along the sleeve" (xZsrMfCI1Wg [00:53:19]). Retopology should already put a loop on every garment seam (8wmhYarIyjw [00:08:53]). - Density is intentional and per tile: eyes, head, hands more than shirt (xZsrMfCI1Wg [01:28:32]). - Film symmetry: Mirror modifier with Mirror U + Flip UDIM mirrors each half around its own tile center, so the center line must sit at u = tile + 0.5 (_LI28r-Nk5g [02:05:34]). - UVs are judged at the highest subdivision level, "that's where the uvs need to look really good" [01:08:22]. - Games triangulate the low before baking so baked shading matches the engine (8wmhYarIyjw [00:26:11]). - Align UVs to form flow (hair strands, stitches) on a secondary UV map if not on the main one; turn Live Unwrap off first, it is stored in the Scene (8wmhYarIyjw [01:28:42], [02:00:58]). ## Jonathan Lampel (BCON23) - "Every sharp edge has to be a seam" on assets that get baked normals; otherwise the normal map glitches, because the texture needs overshoot at the island edge (KCKro3eGmLM [00:19:55]). - Seams from Islands before re-unwrapping a projection or primitive UV, or its islands are lost [00:09:56]. [verified on the sheep: the file's seam flags were retopology region marks (23 seam islands, 16 of them rings) while the UV layout had 11 islands; `seams_from_uv_islands` recovers the real seams.] - Straightening recipe: straighten one quad, make it active, Follow Active Quads on the grid region, pin, unwrap the rest so it relaxes around the pinned grid [00:30:09]-[00:33:32]. Straight islands read cleanly at lower resolution and pack tighter [00:29:03], [00:48:14]. [verified headless: torus edges axis-aligned 1.6% to 100%.] - Pack with Cardinal or Axis-Aligned rotation after straightening; Any tilts them [00:35:49]. Average Islands Scale before packing "most of the time" [00:34:43]. - Margin about res/128 for games because mipmaps bleed; film needs less [00:37:29]-[00:38:01]. - Live Unwrap silently resets hand-tuned islands anywhere on the object when a seam is added [00:21:56]. - Sub-D meshes stretch at seams with the default UV Smooth "Keep Boundaries": use Keep Corners and Junctions (On Mars 3D prefers All), or unwrap with Use Subdivision Surface at the render level [00:23:24], [00:17:34]. - Pack by material (texture set), not by object [00:38:59]. ## Jim Morren (CG Boost) - Decomposition: every shape is a cube, cylinder, ring or sphere; cut the caps off and down the length of anything cylindrical (JwkgVckqGw4 [00:17:53]). - Through-holes must be seamed or the unwrap "squeezes through these holes" [00:38:27]. - The active face for Follow Active Quads must already be rectangular, or the whole island skews [00:24:17]. - Sphere poles: seam the pole fans, straighten the quads, pin, re-solve the triangles [00:30:14]-[00:31:52]. - Head seam map: eyelid interior, mouth interior behind the lips, ear bases, jawline to ear, ear to ear at the back hairline, neck ring and a lengthwise neck seam at the back; the face stays seam-free [00:47:16]-[00:51:37]. - Eyes: a back-disc seam alone unwraps inside out; add a meridian seam far back. Scale the iris up, the back of the eye down [00:55:26]-[00:57:05]. - Mirror U gives the mirrored copy unique UVs if the source islands stay on one half [00:57:36]. - "You're going to have stretching regardless of what you do" [00:25:21]. ## On Mars 3D (UV 2026, bakes 2024) - Applying scale "will fix probably the vast majority of your issues"; rectangular checkers on a clean unwrap mean unapplied scale (dm3bBpZVmnE [00:02:20], [00:19:31]). [verified: unwrap and Average Islands Scale work in object space; a 2x unapplied scale halves the density.] - Parts with thickness need the inner wall cut too [00:11:30]. - "You cannot minimize distortion without UV seams" [00:25:16]. The most common bad island is one missed edge [00:18:26]. - Silhouette first: the bake only fakes surface slope; check the low against the high in flat lighting (C_RqdNbYOjE [00:01:02]). Cylinders: 4 sides fail, 6 bare minimum, 8 recommended, 16 clean [00:03:48]. - Every hard edge needs a UV seam, or black and white crack lines appear [00:05:17], [00:08:04]. A fully smooth low pushes gradients that break under compression [00:08:36]. - Padding 16 px at 2K [00:11:18]. Moving parts bake exploded, or neighbors' AO is baked in [00:12:36]. Custom cage when the automatic distance misses a face [00:13:44]. Match high and low by name to stop intersecting parts contaminating each other [00:17:46]. ## Josh Gambrell (Blender Bros) - Unwrap the low, not the high (HDURGTLNu2Q [00:02:22]). Apply form-defining Booleans, delete bakeable ones [00:06:01]. Low-poly bevels always one segment; rounded low bevels bake badly, "especially with ambient occlusion" [00:08:12]. - Ring rule: "imagine a piece of paper taped together, cut it somewhere and flatten it" [01:12:17]. - Overlap identical parts never seen together (Array, half-deleted Mirror); keep visible neighbors and tires unique [01:15:03], [01:15:36], [01:20:28]. - Split into texture sets when one 4K set gives big checkers; balance area; each set costs VRAM [01:24:51], [01:38:24]. Coverage above 70% [01:31:56]. - Match density across sets by scaling the higher set down: "we can always scale in, but we can't scale out"; 10-20% is not noticeable [01:35:11], [01:36:16]. - Triangulate (Keep Normals) before an external bake; `_low` / `_high` names for name matching [01:41:07], [01:44:25]. - Correction [added]: he says DirectX for Unity [01:52:36]; Unity uses OpenGL (Y+), Unreal DirectX (Y-) (Polycount swizzle table). ## Tristan (Huge Menace), texel density - Density = texture pixels per unit of surface, independent of screen size (sR1sAWWT2x8 [00:00:47]). Consistent density is not consistent fidelity [00:05:41]. - Game targets: first person 10.24 px/cm (1024 px/m), first-person weapon 20.48, third person 5.12, top-down 2.56 [00:12:28]. Team decisions, "not gospel" [00:13:03]. - Density first, texture size second: step the size up until the islands fit [00:15:30]. When space is left: strict target, soft target (scale up to fill), atlas, change object scale, non-square texture; "most game studios require at least 75% of UV space to be utilized" [00:16:15]-[00:17:44]. - Delete faces that are never seen [00:10:13]. ## Juan Hernandez (CG Boost), UDIMs - UDIMs are just a UV offset; tile = 1001 + u + 10 v (55sGQLX7iho [00:02:05]). The one rule: no island crosses a tile border [00:05:54]. - Tile count from the closest camera, not ambition; one 4K beats sixteen, but sixteen 4K beat one 16K [00:03:46], [00:07:05]. - Tiling micro-detail (weave, pores) belongs in the shader as tiling maps, not baked [00:15:54]. ## Ryan King, baseline bake - The face gets the biggest island; hidden areas way down (Zxl38NpPWdo [00:05:16]). High and low in the same place [00:06:32]. - Extrusion fixes black or wrong patches where the sculpt pokes outside the low; use "the smallest number that you need", too much makes extruded parts overlap [00:09:49], [00:11:32]. [added, verified: on the sheep, hand-picked 0.02 m / 0.05 m and the measured 0.033 m / 0.066 m gave the same ~7.3% inverted texels, and a sweep from 0.012 to 0.046 m did not move it; the bad texels sat in eye sockets, ear rims, hoof soles and the rear cut, where this retopo and this sculpt differ. When extrusion does not change the artifact, fix the geometry, not the number.] ## Grant Abbitt, Multires bake in 5.x - Even out base face sizes before Multires: detail density follows face size (LvFilHJfinU [00:02:39]). Linear subdivision keeps a hard form [00:03:33]. - Bake from Multires compares viewport level (low) to render level (high): set viewport to 0; Conform Base rather than Apply Base, which distorts [00:19:32]. Check the level-0 silhouette; if the sculpt moved a lot, bake from level 1 [00:20:06]. [verified: equal levels bake a near-flat map.] - Displacement bake is more useful as a cavity/roughness mask than as displacement on a game asset [00:23:52]. ## Nikolay Naydenov (SpeedChar, Gameloft), production frame - Texture budget first: one 2048 for head, body, teeth, eyes; the weapon on half a 1K (mcEcWwcPtmA [00:00:00]). Overlaps are "a no-no" in unique UVs [00:05:17]. Checker: "mostly squares and most squares the same size" [00:09:34]. - Non-manifold edges, stray faces, back faces break unwraps: clean before UVs (mcEcWwcPtmA [00:25:07]; qFMwPSv5KwA [00:36:13]). - Texel priority follows the camera: face front, chest front, arms, upper glove big; back of neck, soles, glove underside small; reversed for behind-the-back cameras (qFMwPSv5KwA [00:11:05], [00:25:22]). - Material name = texture set = texture file name (qFMwPSv5KwA [00:47:46]). Test bake at 512, final 2048 with 4x4 anti-aliasing [00:59:09]. - Diagnosis table (E6QMhGuhyoc): black triangle = UV overlap [00:01:36]; AO ghost where nothing occludes = shared UVs [00:04:56]; straight line in the normal map = triangulation mismatch [00:18:37]; skew or misses = ray distance [00:21:25]. Fixing the distance for the face broke the armpit: "when we fix something somewhere, some other problems occur" [00:26:23]. UVs are "almost never final" [00:07:41]. - Explode along one axis only (Z) so reassembly is exact (speedchar digest, 6MccOhJZXPQ [00:01:06]). ## My measurements in 5.2.1 [added, verified] - `uv.unwrap` returns FINISHED on a closed mesh without seams, prints "Unwrap failed to solve 1 of 1 island(s)" only on the console, and keeps the island's previous UVs, rotated and repacked into the tile (affine fit residual 3e-8). It looks like a valid layout. Zero the UVs before unwrapping and look for zero-area faces after; `unwrap()` does this. - Cutting every island to a topological disk over-cuts: on the sheep the studio's big islands with openings folded into slits. Islands with holes flatten fine when they are flat enough (a washer: spread 1.0, 0 cuts); `cut_to_disks` now test-unwraps them and cuts only when density spread > 2 or angle p95 > 20 deg. - Angle Based folded a 1,180-face body island over itself by 1.2% of its pixels with zero flipped faces (global overlap); Minimum Stretch folded it by 0.02%. `unwrap(method="BEST")` penalizes folds. - Blender's packer, 16 px gap at 2K, organic character islands: 55-62% coverage (CONCAVE, CARDINAL or ANY). Experts' 70-80% needs a dedicated packer (UVPackmaster: Gambrell 85-87%), straightened islands or hand packing. - Selected to Active evaluates the low with its RENDER settings: a low carrying the sculpt as Multires (render level 3) bakes an almost flat map regardless of its viewport level. - AO in Selected to Active: the active low never occludes (an inflated low enclosing the high changed mean AO 0.805 to 0.801); the selected highs occlude whether or not they are hidden from render; every other render-visible object occludes (a cube in front of the face darkened it). - One global extrusion cannot serve a character with thin, close parts. On the sheep, single-pass selected-to-active left 7.3-7.8% inverted texels at every extrusion from 1.2 to 4.6 cm. That held even against the retopo's own Multires level 3 (7.5%), a high that matches the low by construction, while `bake_multires` on the same data gave 0.09%. Per-texel adaptive extrusion (smallest extrusion that hits a front face, from five passes) gave 2.2% and 1.2%, and matched the Multires bake (median texel difference 0.011). This is SpeedChar's rejected per-region idea (E6QMhGuhyoc [00:28:04]), done per pixel. - An automatic cage (push out past the high, capped at half the free space to the next surface) baked worse (9.5%) than one distance; kept as `build_cage`, marked experimental. - The sheep retopo carries a "body pose" shape key at 1.0: the bake uses that posed shape. Applying a Shrinkwrap for a fitting pass needs the key mix applied first (`shape_key_remove(all=True, apply_mix=True)`), or the modifier refuses and clearing keys reverts to the unposed basis. - The distiller's nearest-point cage estimate (from every high vertex to the low) is inflated by high parts the low does not cover (the sheep's tail); measuring along the low's normals is not. - A layer reference `me.uv_layers[...]` taken before an Edit Mode round trip becomes stale and can resolve to another attribute (`.corner_edge`); keep the layer name. -
procedures.md 17.6 KB
# Procedures (tested on Blender 5.2.1 LTS) Two layers: 1. **`scripts/bx_uvbake.py`**: the tested module (use it whenever you can import a file; it works headless and in a live session). Pipelines below. Tests: `tests/code/blender-uv-baking/test_uv_primitives.py` (primitives), `test_sheep_uv.py` and `test_sheep_bake.py` (Blender Studio sheep, CC-BY), `test_snippets.py` (this file's snippets). 2. **Raw bpy snippets** for when only inline code is possible. Every block tagged `# snippet:` is executed verbatim by `tests/code/blender-uv-baking/test_snippets.py` (it parses this file). Each block builds its own demo objects so it runs alone; replace the demo part with your objects. Import pattern: ``` import sys; sys.path.append("<skills>/scenario-blender-uv-baking/scripts"); import bx_uvbake as ub ``` ## Pipeline A: game hard-surface prop (verified, test_uv_primitives.py sections A, E, F, H) ``` ub.prepare_low(low) # apply scale, smooth shading ub.seams_from_sharp(low, angle_deg=30, mark_sharp=True) # every hard edge a seam, sharps = seams ub.cut_to_disks(low, view_dirs=[(0, -1, 0)]) # rings/closed parts get one hidden cut ub.unwrap(low, method="BEST") # Conformal/Angle Based/Min Stretch per island ub.straighten(low) # grid-like islands, before any scaling ub.normalize_texel_density(low, ub.TD_TARGETS["fps"], 2048) # 1024 px/m, strict ub.texture_size_for(low, 1024, 0.75) # does 2048 hold it? else 4096 or a second set ub.pack(low, tex_res=2048, scale=False) # keep density; fits_tile False = raise res/split q = ub.uv_qa(low, 2048); print(ub.uv_verdict(q, "game")) ub.prepare_low(low, sharp_from_uv_islands=True, triangulate=True) # hard edges = island borders, ship tris r = ub.bake_high_to_low(low, [high], out_dir, res=2048, maps=("NORMAL", "AO"), directx=False) ``` ## Pipeline B: game character (verified on the sheep, test_sheep_uv.py, test_sheep_bake.py) ``` ub.seams_from_uv_islands(low) # only if a layout exists: recover its seams (file seam flags may be retopo marks) # or: ub.seams_from_groups(low, "FACE_SETS") / labels per face from your part segmentation ub.cut_to_disks(low, view_dirs=[(0, -1, 0)]) # measured: islands with holes kept unless they stretch u = ub.unwrap(low, method="BEST") if u["folding_faces"]: # a limb folded onto the body ub.seams_from_groups(low, "FACE_SETS", faces=u["folding_faces"]); ub.cut_to_disks(low); ub.unwrap(low, method="BEST") w = ub.face_weights_from_vgroup(low, "head", 1.0, 1.5) # or any per-face weights (face 1.5-2, soles 0.5) ub.normalize_texel_density(low, None, 2048, weights=w) ub.pack(low, tex_res=2048, allow_any=True) # organic islands: free rotation allowed ub.uv_layout_image(low, out + "/uv.png"); ub.checker_review([low], out); ub.distortion_review(low, out) ub.prepare_low(low) r = ub.bake_high_to_low(low, [sculpt], out, res=2048, maps=("NORMAL", "AO")) # adaptive extrusion by default ub.bake_review(low, [sculpt], r["paths"], out) # LOOK at it if r["sanity"]["NORMAL"]["problems"] or r["sanity"]["NORMAL"]["notes"]: ub.map_problem_review(low, r["paths"]["NORMAL"], out) # where are the bad texels (Pipeline E) ub.curvature_from_normal(r["paths"]["NORMAL"], out + "/curv.png", mask=ub.island_mask(low, 2048)) ub.bake_id(low, [sculpt], out, source="MATERIAL") # or "OBJECT", or "ATTRIBUTE" + attribute="Col" ub.hookup_maps(low, normal=r["paths"]["NORMAL"], ao=r["paths"]["AO"]) ``` Results on the sheep (2,596-face retopo, 186k-face sculpt, 2048): `archive/tests/uv_baking_skill/out_sheep_uv/results.json` and `out_sheep_bake/results.json` (renders next to them). ## Pipeline C: film character, UDIMs and symmetry - Tiles per region from the closest shot (Hernandez): head 1001, body 1002, hands 1003 (Kaspar). Per region: `ub.pack(obj, tex_res=4096, faces=region_faces)` (the region alone into 0-1), then `ub.move_to_tile(obj, region_faces, tile)`. Do not rely on `udim_source="CLOSEST_UDIM"` headless: it packed a region sitting in 1002 back into 1001 (verified, test_uv_primitives.py section M). Raw equivalent of the move: snippet `udim_offset`. - Symmetric UVs: Mirror modifier with Mirror U + Flip UDIM, center line at u = tile + 0.5 (snippet `mirror_u_flip_udim`). - QA: `ub.uv_qa(obj, 4096)` reports per-tile coverage, gap and `faces_crossing_tiles` (must be 0); use `profile="film"` in `uv_verdict`. - UDIM bake target: snippet `udim_bake`. ## Pipeline D: Multires bake (Abbitt; verified on the sheep) ``` ub.bake_multires(obj, out, res=2048, types=("NORMALS", "DISPLACEMENT"), base_level=0) ``` Before: even base face sizes, UVs on the base, Conform Base if the sculpt moved the silhouette (`bpy.ops.object.multires_base_apply(modifier="Multires", apply_heuristic=False)` is presumably Conform Base; mapping not verified), shade smooth. ## Pipeline E: bake loop when the first bake is not clean (SpeedChar, automated) 1. Read `r["sanity"][kind]["problems"]` / `["notes"]`, `r["projection"]` (`miss_pct`, `wrong_part_pct`) and `r["adaptive"]["texel_share"]` (which extrusion each texel used). 2. `ub.map_problem_review(low, r["paths"]["NORMAL"], out)`: renders the low with bad texels painted red (inverted) and blue (missed). Look at where they are. 3. `ub.sweep_projection(low, highs, out)`: single-pass bakes at 0.35-1.4 x the measured extrusion. If the inverted share moves, the distance is the problem: use the best factor, a cage, or `adaptive=(...)` with more factors. If it does not move (sheep: 7.3-7.8% at every factor), low and high differ there: fit the low (SpeedChar's fitting pass), close holes in the high, give thin parts more low geometry, or bake that part separately. 4. Neighboring parts projecting into each other (fingers, ears, stacked parts): `ub.explode([[low_a, high_a], [low_b, high_b]])`, bake, `ub.unexplode(...)`; or bake each pair into the same image (snippet `per_pair_bake`). 5. Re-bake only the map being tuned, at 512, then the final resolution. 6. A hand-shaped cage (snippet `cage_object`) is the manual fallback. `ub.build_cage` (automatic) is EXPERIMENTAL: on the sheep it baked worse (9.5% inverted) than one measured distance (7.3%) and the adaptive bake (2.2%). Verified numbers (test_sheep_bake.py): sculpt onto retopo, single pass 7.3% inverted, adaptive 2.2%; retopo's own Multires level 3 as a separate high, single 7.5%, adaptive 1.2%, and the adaptive map matches `bake_multires` (median texel difference 0.011, p95 0.19). ## Raw bpy snippets ### Unwrap with an explicit method and catch a silent failure ```python # snippet: unwrap_explicit import bpy, bmesh import numpy as np bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8) # demo: closed, no seams obj = bpy.context.active_object for e in obj.data.edges: e.use_seam = False lay = obj.data.uv_layers.active lay.uv.foreach_set("vector", np.zeros(len(obj.data.loops) * 2)) # zero first: a failed island stays zero bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT') res = bpy.ops.uv.unwrap(method='ANGLE_BASED', fill_holes=True, margin=0.001) # always pass method bpy.ops.object.mode_set(mode='OBJECT') lay = obj.data.uv_layers.active # re-fetch: references go stale uv = np.empty(len(obj.data.loops) * 2); lay.uv.foreach_get("vector", uv) unsolved = np.ptp(uv.reshape(-1, 2), axis=0).max() < 1e-9 assert res == {'FINISHED'} and unsolved # FINISHED, yet unsolved; without zeroing it keeps the OLD UVs, repacked ``` ### Pin and re-unwrap (the headless Live Unwrap) ```python # snippet: pin_reunwrap import bpy, bmesh bpy.ops.mesh.primitive_grid_add(x_subdivisions=6, y_subdivisions=6) # demo obj = bpy.context.active_object bpy.ops.object.mode_set(mode='EDIT') bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active corner = min(bm.verts, key=lambda v: v.co.x + v.co.y) for l in corner.link_loops: l[uvl].uv = (0.0, 0.0); l[uvl].pin_uv = True # pin and place bmesh.update_edit_mesh(obj.data) bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.0) # pinned UVs stay exactly where placed bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active corner = min(bm.verts, key=lambda v: v.co.x + v.co.y) assert all(l[uvl].uv.length < 1e-6 for l in corner.link_loops) bpy.ops.uv.pin(clear=True) bpy.ops.object.mode_set(mode='OBJECT') ``` ### Pack with a pixel gap ```python # snippet: pack_pixel_gap import bpy bpy.ops.mesh.primitive_cube_add() # demo: default cube UVs, 6 faces obj = bpy.context.active_object res, gap_px = 2048, 2048 / 128 # Lampel: res/128 for games (16 px at 2K) bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.seams_from_islands() bpy.ops.uv.unwrap(method='CONFORMAL', margin=0.0) bpy.ops.uv.average_islands_scale() r = bpy.ops.uv.pack_islands(udim_source='CLOSEST_UDIM', rotate=True, rotate_method='CARDINAL', scale=True, margin_method='FRACTION', margin=gap_px / (2 * res), shape_method='CONCAVE') # FRACTION m: gap 2m between islands, m to the border bpy.ops.object.mode_set(mode='OBJECT') uvs = [d.vector for d in obj.data.uv_layers.active.uv] border_px = min(min(u.x, u.y, 1 - u.x, 1 - u.y) for u in uvs) * res assert r == {'FINISHED'} and 6 <= border_px <= 10, border_px ``` ### Selected-to-active bake with every setting explicit ```python # snippet: s2a_bake import bpy bpy.ops.mesh.primitive_uv_sphere_add(segments=64, ring_count=32); high = bpy.context.active_object d = high.modifiers.new("d", 'DISPLACE'); d.texture = bpy.data.textures.new("n", 'CLOUDS'); d.strength = 0.05 bpy.ops.object.shade_smooth() bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8); low = bpy.context.active_object bpy.ops.object.shade_smooth() # demo ends; low has UVs, high is detailed sc = bpy.context.scene; sc.render.engine = 'CYCLES'; sc.cycles.samples = 16 # >1 antialiases img = bpy.data.images.new("low_normal", 512, 512, alpha=False, float_buffer=True) img.colorspace_settings.name = 'Non-Color' mat = low.active_material or bpy.data.materials.new("low_mat") if not low.data.materials: low.data.materials.append(mat) nt = mat.node_tree node = nt.nodes.new("ShaderNodeTexImage"); node.image = img for n in nt.nodes: n.select = False node.select = True; nt.nodes.active = node # 5.x: selected AND active, else CANCELED silently bpy.ops.object.select_all(action='DESELECT') high.select_set(True); low.select_set(True); bpy.context.view_layer.objects.active = low r = bpy.ops.object.bake(type='NORMAL', use_selected_to_active=True, cage_extrusion=0.06, max_ray_distance=0.12, # ray counts FROM the extruded start margin=4, margin_type='EXTEND', use_clear=True, target='IMAGE_TEXTURES', normal_space='TANGENT', normal_r='POS_X', normal_g='POS_Y', normal_b='POS_Z') assert r == {'FINISHED'}, r # always assert: failures do not raise img.filepath_raw = bpy.app.tempdir + "low_normal.png"; img.file_format = 'PNG'; img.save() # 16-bit PNG ``` ### AO bake: distance and occluders ```python # snippet: ao_setup import bpy sc = bpy.context.scene sc.world = sc.world or bpy.data.worlds.new("World") low = bpy.context.active_object sc.world.light_settings.distance = 0.2 * low.dimensions.length # default is 10 units: far too big for props keep = {low.name} | {o.name for o in bpy.context.selected_objects} for o in sc.objects: # every render-visible non-selected object occludes if o.name not in keep: o.hide_render = True sc.cycles.samples = 128 # AO is noisy; normals are fine at 16 assert sc.world.light_settings.distance < 10 ``` ### DirectX output (Unreal) and reading a DirectX map ```python # snippet: directx import bpy b = bpy.context.scene.render.bake b.normal_g = 'NEG_Y' # bake writes G = 1 - G (DirectX, Y-) mat = bpy.data.materials.new("read_dx") nm = mat.node_tree.nodes.new("ShaderNodeNormalMap") nm.convention = 'DIRECTX' # 5.1+: read a DirectX map in Blender assert b.normal_g == 'NEG_Y' and nm.convention == 'DIRECTX' b.normal_g = 'POS_Y' ``` ### Multires bake ```python # snippet: multires_bake import bpy bpy.ops.mesh.primitive_cube_add(); obj = bpy.context.active_object bpy.ops.object.mode_set(mode='EDIT'); bpy.ops.mesh.select_all(action='SELECT') bpy.ops.uv.smart_project(island_margin=0.02); bpy.ops.object.mode_set(mode='OBJECT') mr = obj.modifiers.new("Multires", 'MULTIRES') for _ in range(3): bpy.ops.object.multires_subdivide(modifier="Multires", mode='CATMULL_CLARK') bpy.ops.object.shade_smooth() # demo ends: base cube, round level 3 sc = bpy.context.scene; sc.render.engine = 'CYCLES' mr.levels = 0; mr.render_levels = mr.total_levels # low = viewport level, high = render level img = bpy.data.images.new("mr_normal", 256, 256, float_buffer=True); img.colorspace_settings.name = 'Non-Color' mat = bpy.data.materials.new("mr"); obj.data.materials.append(mat) node = mat.node_tree.nodes.new("ShaderNodeTexImage"); node.image = img for n in mat.node_tree.nodes: n.select = False node.select = True; mat.node_tree.nodes.active = node b = sc.render.bake; b.use_multires = True; b.type = 'NORMALS'; b.margin = 4; b.use_clear = True assert bpy.ops.object.bake_image() == {'FINISHED'} # no arguments: reads scene.render.bake b.use_multires = False ``` ### UDIM target that bakes ```python # snippet: udim_bake import bpy img = bpy.data.images.new("body_udim", 256, 256, float_buffer=True, tiled=True) # tile 1001 with bpy.context.temp_override(edit_image=img): bpy.ops.image.tile_add(number=1002, count=1, fill=True, width=256, height=256, float=True, generated_type='BLANK') # fill=True: tiles.new() alone = "Uninitialized image" img.filepath_raw = bpy.app.tempdir + "body_udim.<UDIM>.exr" assert [t.number for t in img.tiles] == [1001, 1002] ``` ### Move a region to a UDIM tile ```python # snippet: udim_offset import bpy, bmesh bpy.ops.mesh.primitive_cube_add(); obj = bpy.context.active_object # demo tile = 1002 du, dv = (tile - 1001) % 10, (tile - 1001) // 10 bpy.ops.object.mode_set(mode='EDIT') bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active for f in bm.faces: if f.normal.z > 0.5: # demo region: the top face for l in f.loops: l[uvl].uv.x += du; l[uvl].uv.y += dv # uv.move_on_axis needs a UV editor bmesh.update_edit_mesh(obj.data); bpy.ops.object.mode_set(mode='OBJECT') assert max(d.vector.x for d in obj.data.uv_layers.active.uv) > 1.0 ``` ### Mirror U + Flip UDIM (film symmetry) ```python # snippet: mirror_u_flip_udim import bpy bpy.ops.mesh.primitive_monkey_add(); obj = bpy.context.active_object # demo m = obj.modifiers.new("Mirror", 'MIRROR') m.use_axis[0] = True; m.use_bisect_axis[0] = True # keep one half, mirror it m.use_mirror_u = True; m.use_mirror_udim = True # mirror each half around its own tile center assert m.use_mirror_u and m.use_mirror_udim # the center line must sit at u = tile + 0.5 ``` ### Offset a stacked copy one tile before baking ```python # snippet: offset_stacked import bpy, bmesh bpy.ops.mesh.primitive_plane_add(); obj = bpy.context.active_object # demo: one face bpy.ops.object.mode_set(mode='EDIT') bm = bmesh.from_edit_mesh(obj.data); uvl = bm.loops.layers.uv.active for f in bm.faces: # demo "duplicate" = every face here for l in f.loops: l[uvl].uv.x += 1.0 # outside 0-1: the baker skips it, the shader bmesh.update_edit_mesh(obj.data); bpy.ops.object.mode_set(mode='OBJECT') # wraps, so it reads the same texels assert min(d.vector.x for d in obj.data.uv_layers.active.uv) >= 1.0 ``` ### Custom cage object ```python # snippet: cage_object import bpy bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8); low = bpy.context.active_object # demo cage = low.copy(); cage.data = low.data.copy(); cage.name = low.name + "_cage" bpy.context.scene.collection.objects.link(cage) for v in cage.data.vertices: v.co += v.normal * 0.05 # shrink by hand where parts are close (fingers) cage.hide_render = True b = bpy.context.scene.render.bake b.use_cage = True; b.cage_object = cage # same topology and face order as the low assert b.cage_object == cage b.use_cage = False; b.cage_object = None ``` ### Several high/low pairs into one image (Blender has no match-by-name) ```python # snippet: per_pair_bake import bpy def bake_pairs(pairs, **kw): """pairs: [(low, [highs]), ...] sharing one bake image; clear only on the first pass.""" for i, (low, highs) in enumerate(pairs): bpy.ops.object.select_all(action='DESELECT') for h in highs: h.select_set(True) low.select_set(True); bpy.context.view_layer.objects.active = low r = bpy.ops.object.bake(use_selected_to_active=True, use_clear=(i == 0), **kw) assert r == {'FINISHED'}, r assert callable(bake_pairs) ``` ### Game low: triangulate what you ship ```python # snippet: triangulate import bpy bpy.ops.mesh.primitive_cube_add(); low = bpy.context.active_object # demo t = low.modifiers.new("Triangulate", 'TRIANGULATE') t.keep_custom_normals = True # Gambrell's "Keep Normals" t.quad_method = 'BEAUTY' # whatever it is, export the same (apply or FBX use_triangles) assert t.keep_custom_normals ``` -
sources.md 12.3 KB
# Sources Per-video notes live in `notes/uv-baking/` (and `notes/retopology/_digest_speedchar_game_character.md` for the SpeedChar production frame). Timestamps are [hh:mm:ss] into the video. ## Videos | Expert, credential | Video | Blender | Best for | Best timestamps | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Jonathan Lampel, CG Cookie instructor, pipeline and add-on developer at Orange Turbine, author of UV Flow | [Unwrapping a Faster UV Workflow, BCON23](https://www.youtube.com/watch?v=KCKro3eGmLM) (49 min, 2023) | 3.6/4.0 | Production judgment: method choice, every sharp edge is a seam for baked assets, straighten-pin-unwrap recipe, pack rotation, margin res/128, pack per material | [00:16:28] methods, [00:19:23] sharp = seam, [00:21:56] Live Unwrap resets work, [00:30:09]-[00:33:32] straightening, [00:35:16] Cardinal rotation, [00:37:29] margin | | Jim Morren, CG Boost instructor (Master 3D Characters) | [UV Unwrapping in Blender, full tutorial](https://www.youtube.com/watch?v=JwkgVckqGw4) (60 min, 2025) | 4.3+ | Primitive decomposition (cube, cylinder, ring, sphere), holes, pins, head and eye seams, texel priority, Mirror U | [00:17:53] cylinder rule, [00:24:17] active quad must be straight, [00:30:14] sphere poles, [00:38:27] through-holes, [00:43:56] methods on Suzanne, [00:47:16]-[00:51:37] head seam map, [00:55:26] eye, [00:57:36] Mirror U | | On Mars 3D, game-art school instructor | [Blender 5.0: How to UV Unwrap Anything](https://www.youtube.com/watch?v=dm3bBpZVmnE) (29 min, 2026) | 5.0 | Only 5.x UV video: apply scale, walls with thickness, Sub-D UV smooth, method switch on hard surface, 5.0 layout tools | [00:02:20] apply scale, [00:11:30] inner wall seam, [00:13:24] UV Smooth, [00:17:28] Conformal on hard surface, [00:18:26] one missed edge, [00:21:56] 5.0 tools | | Josh Gambrell, Blender Bros founder, hard-surface instructor | [Become a BOSS at UV Unwrapping](https://www.youtube.com/watch?v=HDURGTLNu2Q) (1 h 55, 2021) | ~3.0 | Hard-surface game pipeline: bake vs model, one-segment low bevels, ring rule, overlapping repeats, texture sets, coverage, density matching, triangulate, naming | [00:07:06] one-segment bevel, [01:02:31] sharp seams + Conformal, [01:12:17] ring rule, [01:15:03] overlaps, [01:24:51] texture sets, [01:31:56] >70% coverage, [01:35:11] scale the higher set down, [01:41:07] triangulate | | Juan Hernandez, CG Boost creature artist | [Introduction to UDIMs](https://www.youtube.com/watch?v=55sGQLX7iho) (21 min, 2021) | 2.93 | UDIM numbering, no island across tiles, tile count from the closest camera, tiling detail belongs in the shader | [00:02:05] numbering, [00:05:54] the one rule, [00:07:05] closest camera, [00:15:54] shader tiling | | Tristan, Huge Menace | [Texel Density Masterclass](https://www.youtube.com/watch?v=sR1sAWWT2x8) (19 min, 2024) | ~4.1 | Game density targets, density first then texture size, strict vs soft target, atlas, non-square | [00:05:41] density is not fidelity, [00:12:28] targets, [00:15:30] choose size from density, [00:16:15]-[00:17:44] five options | | Julien Kaspar, Blender Studio character artist (Rain, Snow, Sprite Fright) | [Snow UV Unwrapping Live #1](https://www.youtube.com/watch?v=_LI28r-Nk5g) (2 h 13, 2021) | 2.93 | Film character body: seam placement, pin + Live Unwrap relaxation, closed eyelids, symmetry, 3 UDIM tiles | [00:10:01] hide seams, [00:14:44] UDIM plan, [00:36:45] separate wrapping sides, [00:50:33] pins, [00:56:16] close eyes in UV, [01:40:04] two pins per island, [02:05:34] Mirror U + Flip UDIM | | Julien Kaspar | [Snow UV Unwrapping Live #2](https://www.youtube.com/watch?v=xZsrMfCI1Wg) (2 h 08, 2021) | 2.93 | Clothing UVs on sewing lines, straightened fabric grain, intentional density policy | [00:17:28] Live Unwrap instability = missing seam, [00:53:19] fabric direction, [01:09:36] overlapping pins, [01:28:32] eyes denser than shirt | | Julien Kaspar | [Snow UV Unwrapping Live #3](https://www.youtube.com/watch?v=8wmhYarIyjw) (2 h 11, 2021) | 2.93 | Secondary objects (eyes, teeth, hair), UVs along shape flow, why games triangulate before baking, film vs game symmetry | [00:26:11] triangulate before baking, [01:28:42] secondary UV map, [01:31:27] unpin thickness loops, [01:58:10] games ignore symmetry | | Ryan King, Blender educator | [Texture Bake High Poly Details to a Low Poly Mesh](https://www.youtube.com/watch?v=Zxl38NpPWdo) (14 min, 2024) | ~4.1 | Baseline selected-to-active normal bake and the extrusion fix | [00:05:16] island size = texels, [00:07:33] 32-bit float Non-Color, [00:09:49] extrusion 0.01, [00:10:26] raised to 0.02, [00:11:32] smallest that works | | On Mars 3D | [5 Tips to Master Texture Bakes Like a Pro](https://www.youtube.com/watch?v=C_RqdNbYOjE) (20 min, 2024) | 4.0 + Substance | Game bake judgment: silhouette, cylinder sides, hard edges need seams, gradients vs compression, padding, exploded bakes, cage, match by name | [00:03:48] 6 min / 8 recommended sides, [00:08:04] seams on hard edges, [00:08:36] gradients, [00:11:18] 16 px at 2K, [00:12:36] explode, [00:14:16] custom cage, [00:17:46] match by name | | Grant Abbitt, GameDev.tv educator | [Turn Low Poly Models into AAA Game Assets, Blender 5](https://www.youtube.com/watch?v=LvFilHJfinU) (27 min, 2026) | 5.1.2 | Multires bake in 5.x: even base faces, Linear subdivide, Conform Base, viewport vs render level, displacement as a mask | [00:02:39] face size, [00:03:33] Linear, [00:18:25] 32-bit is for film, [00:19:32] Conform Base, [00:20:38] Bake from Multires, [00:23:52] displacement as cavity | | Nikolay Naydenov (SpeedChar), character artist at Gameloft Bulgaria | [Making UVs, ep36](https://www.youtube.com/watch?v=mcEcWwcPtmA) (51 min, 2022) | 3.1 | Texture budget first, no overlaps, checker judgment, non-manifold check before UVs | [00:00:00] budget, [00:05:17] overlaps are a no-no, [00:09:34] checker rule, [00:25:07] non-manifold | | Nikolay Naydenov | [Finish UVs and baking, ep37](https://www.youtube.com/watch?v=qFMwPSv5KwA) (64 min, 2022) | 3.1 + Substance | Texel priority from the camera, unique UVs where AO differs, material = texture set name, test bake 512 then 2048 | [00:04:29] unique vs mirrored, [00:11:05] priorities, [00:47:46] material naming, [00:59:09] test bake | | Nikolay Naydenov | [Baking fixes, ep38](https://www.youtube.com/watch?v=E6QMhGuhyoc) (40 min, 2022) | 3.1 + Substance | Real artifact diagnosis loop and the ray-distance sweep trade-off | [00:01:36] black triangle = overlap, [00:04:56] AO ghost = shared UVs, [00:18:37] triangulation line, [00:21:25]-[00:28:04] distance sweep | ## Web references (read by the distiller) - Blender Studio, [A UV Unwrapping Guide](https://studio.blender.org/blog/a-uv-unwrapping-guide/) (Julien Kaspar with Simon Thommes, 2024-10-28): more seams rather than fewer for 3D paint and bakes, angle past green means tweak, Live Unwrap is stored in the Scene and breaks on flipped UVs, symmetric maps via the Mirror modifier, secondary UV maps for tiled patterns, fabric-pattern clothing UVs. Its paywalled follow-ups were not read. - Blender 5.2 Manual, [Render Baking](https://docs.blender.org/manual/en/latest/render/cycles/baking.html): cage and extrusion, Max Ray Distance, margin types, Clear Image, Bake from Multires, memory note on many high objects. - Polycount wiki, [Normal map](http://wiki.polycount.com/wiki/Normal_map) and its Technical Details page (swizzle table: Blender, Maya, Unity, Toolbag Y+; 3ds Max, Unreal, CryEngine, Source Y-), and [Texture Baking](http://wiki.polycount.com/wiki/Texture_Baking): split UVs on every hard edge, offset overlaps exactly 1 UV unit, triangulate before baking and mirror the triangulation, bake 16-bit with anti-aliasing, do not edit UVs after baking. ## Digests - `notes/uv-baking/_digest_uv_unwrapping.md` (Lampel, Morren, On Mars 3D, Gambrell, Hernandez, Huge Menace; verification scripts in `archive/tests/uv_unwrapping/`). - `notes/uv-baking/_digest_uv_snow_and_baking.md` (Kaspar x3, Ryan King, On Mars 3D bakes, Abbitt, web references; scripts in `archive/tests/uv_snow_and_baking/`). - `notes/retopology/_digest_speedchar_game_character.md` (SpeedChar production frame, bake diagnosis).
-
-
scripts
-
bx_uvbake.py 105 KB
""" bx_uvbake: UV unwrapping, UV QA and high-to-low baking for Blender 5.2 (headless-safe). Every public function works from Object Mode, restores selection, active object, modes and render settings it touched, and returns a plain dict an agent can print as JSON. import sys; sys.path.append("<skills>/scenario-blender-uv-baking/scripts"); import bx_uvbake as ub # seams ub.seams_from_sharp(obj, angle_deg=30) # hard-surface: every hard edge is a seam ub.seams_from_groups(obj, "FACE_SETS") # organic: seams on region borders ub.cut_to_disks(obj, view_dirs=[(0, -1, 0)]) # ring / closed-shape rule, hidden side ub.island_topology(obj) # which islands are not disks yet # unwrap, density, pack ub.unwrap(obj, method="ANGLE_BASED") # or "MINIMUM_STRETCH", "CONFORMAL", "BEST" ub.straighten(obj) # grid islands: Follow Active Quads + pins ub.normalize_texel_density(obj, 512, 2048) # px/m target, optional priority weights ub.pack(obj, tex_res=2048) # pixel gap = res/128, no tilted islands ub.uv_qa(obj, tex_res=2048); ub.uv_verdict(report) ub.uv_layout_image(obj, "/abs/uv.png"); ub.checker_review([obj], "/abs/dir") # bake ub.prepare_low(low) # smooth shading, scale, optional sharps r = ub.bake_high_to_low(low, [high], "/abs/dir", res=2048, maps=("NORMAL", "AO")) r["paths"]["NORMAL"], r["sanity"]["NORMAL"]["problems"] ub.bake_multires(obj, "/abs/dir"); ub.bake_id(...); ub.curvature_from_normal(...) ub.bake_review(low, [high], {"NORMAL": path, "AO": path}, "/abs/dir") Tested on Blender 5.2.1 LTS (tests/code/blender-uv-baking/). Facts the code relies on (all verified there): uv.unwrap defaults to CONFORMAL and returns FINISHED even when an island fails to solve; pack_islands FRACTION margin m leaves 2m between islands and m to the tile border; bake writes only to an image node that is selected AND active and returns CANCELLED silently otherwise; selected-to-active evaluates the low with RENDER settings (Multires render_levels), measures max_ray_distance from the extruded start, never lets the active low occlude its own AO, but every other render-visible object does. """ __version__ = "0.1" # Blender Expert Skills v0.1 (2026-09-24) import colorsys import heapq import math import os from contextlib import contextmanager import bmesh import bpy import numpy as np from mathutils import Vector from mathutils.bvhtree import BVHTree ADAPTIVE_FACTORS = (0.05, 0.15, 0.35, 0.7, 1.0) # Game texel density targets in px/m (Huge Menace, sR1sAWWT2x8 [00:12:28]) TD_TARGETS = {"fps": 1024.0, "fps_weapon": 2048.0, "third_person": 512.0, "top_down": 256.0} SENTINEL = -7.0 # --------------------------------------------------------------------------- helpers def _object_mode(): if bpy.context.object is not None and bpy.context.object.mode != "OBJECT": bpy.ops.object.mode_set(mode="OBJECT") def _layer(me, uv_map=None): if uv_map: return me.uv_layers[uv_map] if me.uv_layers.active is None: raise ValueError(f"mesh {me.name} has no UV map") return me.uv_layers.active def _get_uv(me, uv_map=None): lay = _layer(me, uv_map) a = np.empty(len(me.loops) * 2, dtype=np.float64) lay.uv.foreach_get("vector", a) return a.reshape(-1, 2) def _set_uv(me, uv, uv_map=None): _layer(me, uv_map).uv.foreach_set("vector", np.ascontiguousarray(uv, dtype=np.float64).ravel()) me.update() def _get_pins(me, uv_map=None): lay = _layer(me, uv_map) att = me.attributes.get(".pn." + lay.name) if att is None: return np.zeros(len(me.loops), dtype=bool) a = np.zeros(len(me.loops), dtype=bool) att.data.foreach_get("value", a) return a def _set_pins(me, pins, uv_map=None): lay = _layer(me, uv_map) name = ".pn." + lay.name att = me.attributes.get(name) or me.attributes.new(name, "BOOLEAN", "CORNER") att.data.foreach_set("value", np.ascontiguousarray(pins, dtype=bool)) @contextmanager def _edit_mode(objs, uv_map=None, select_all=True): """Enter Edit Mode on objs with UV sync selection on; restore everything after.""" vl = bpy.context.view_layer _object_mode() prev_sel = [o for o in vl.objects if o.select_get()] prev_act = vl.objects.active hidden = [o for o in objs if o.hide_get()] for o in hidden: o.hide_set(False) prev_uv = {o.name: o.data.uv_layers.active_index for o in objs} if uv_map: for o in objs: o.data.uv_layers.active = o.data.uv_layers[uv_map] ts = bpy.context.scene.tool_settings prev_sync = ts.use_uv_select_sync ts.use_uv_select_sync = True for o in vl.objects: o.select_set(False) for o in objs: o.select_set(True) vl.objects.active = objs[0] bpy.ops.object.mode_set(mode="EDIT") if select_all: bpy.ops.mesh.select_all(action="SELECT") try: yield finally: bpy.ops.object.mode_set(mode="OBJECT") ts.use_uv_select_sync = prev_sync for o in objs: o.data.uv_layers.active_index = prev_uv[o.name] for o in hidden: o.hide_set(True) for o in vl.objects: o.select_set(o in prev_sel) vl.objects.active = prev_act class _Arrays: """Numpy view of a mesh: world positions, loops, faces, UVs.""" def __init__(self, obj, uv_map=None, evaluated=False): self._eo = None if evaluated: dg = bpy.context.evaluated_depsgraph_get() self._eo = obj.evaluated_get(dg) me = self._eo.to_mesh() mw = self._eo.matrix_world else: me = obj.data mw = obj.matrix_world self.me = me nv, nl, nf = len(me.vertices), len(me.loops), len(me.polygons) co = np.empty(nv * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) M = np.array(mw) self.co = co @ M[:3, :3].T + M[:3, 3] self.lv = np.empty(nl, dtype=np.int64) me.loops.foreach_get("vertex_index", self.lv) self.le = np.empty(nl, dtype=np.int64) me.loops.foreach_get("edge_index", self.le) self.ls = np.empty(nf, dtype=np.int64) me.polygons.foreach_get("loop_start", self.ls) self.lt = np.empty(nf, dtype=np.int64) me.polygons.foreach_get("loop_total", self.lt) self.nf, self.nl, self.ne = nf, nl, len(me.edges) self.lf = np.repeat(np.arange(nf), self.lt) pos = np.arange(nl) - self.ls[self.lf] self.nxt = self.ls[self.lf] + (pos + 1) % self.lt[self.lf] self.prv = self.ls[self.lf] + (pos - 1) % self.lt[self.lf] self.uv = _get_uv(me, uv_map) if me.uv_layers else None def free(self): if self._eo is not None: self._eo.to_mesh_clear() self._eo = None def _union_find(n, a, b): parent = list(range(n)) def find(x): while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for x, y in zip(a.tolist(), b.tolist()): rx, ry = find(x), find(y) if rx != ry: parent[rx] = ry roots = np.array([find(i) for i in range(n)], dtype=np.int64) return np.unique(roots, return_inverse=True)[1] def _edge_pairs(A): """Loops sharing an edge: returns (edge_ids, c1, c2) for edges used by exactly 2 loops.""" order = np.argsort(A.le, kind="stable") les = A.le[order] uniq, start, cnt = np.unique(les, return_index=True, return_counts=True) two = cnt == 2 return uniq[two], order[start[two]], order[start[two] + 1] def _uv_islands(A, eps=1e-5): """UV islands (faces connected by edges whose UVs match on both sides) and the set of UV-continuous edge ids.""" e, c1, c2 = _edge_pairs(A) uv = A.uv opp = A.lv[c1] == A.lv[A.nxt[c2]] m_opp = (np.abs(uv[c1] - uv[A.nxt[c2]]).max(1) < eps) & (np.abs(uv[A.nxt[c1]] - uv[c2]).max(1) < eps) m_same = (np.abs(uv[c1] - uv[c2]).max(1) < eps) & (np.abs(uv[A.nxt[c1]] - uv[A.nxt[c2]]).max(1) < eps) con = np.where(opp, m_opp, m_same) labels = _union_find(A.nf, A.lf[c1][con], A.lf[c2][con]) return labels, e[con] def _seam_islands(obj): """Face groups separated by seams, mesh borders and non-manifold edges.""" me = obj.data A = _Arrays(obj) seam = np.zeros(len(me.edges), dtype=bool) me.edges.foreach_get("use_seam", seam) e, c1, c2 = _edge_pairs(A) keep = ~seam[e] return _union_find(A.nf, A.lf[c1][keep], A.lf[c2][keep]) def _face_measures(A): uv, nxt = A.uv, A.nxt cr = uv[:, 0] * uv[nxt, 1] - uv[nxt, 0] * uv[:, 1] a_uv = 0.5 * np.bincount(A.lf, cr, minlength=A.nf) P = A.co[A.lv] c = np.cross(P, P[nxt]) a3 = 0.5 * np.linalg.norm(np.stack([np.bincount(A.lf, c[:, k], minlength=A.nf) for k in range(3)], 1), axis=1) return a_uv, a3 def _corner_angles(X, A): a = X[A.prv] - X b = X[A.nxt] - X na = np.linalg.norm(a, axis=1) nb = np.linalg.norm(b, axis=1) ok = (na > 1e-12) & (nb > 1e-12) cos = np.einsum("ij,ij->i", a, b) / np.where(ok, na * nb, 1.0) return np.arccos(np.clip(cos, -1, 1)), ok def _wpct(values, weights, q): if len(values) == 0: return float("nan") o = np.argsort(values) v, w = values[o], weights[o] cw = np.cumsum(w) return float(v[min(np.searchsorted(cw, q * cw[-1]), len(v) - 1)]) def _tris(A, uv=None): """Triangulation of every face: (T,3) loop indices and owning face. Quads are split on the diagonal that keeps both UV triangles the same orientation (a fan over a concave UV quad would fake an overlap); n-gons are fanned.""" t0, t1, t2, tf = [], [], [], [] for n in np.unique(A.lt): faces = np.nonzero(A.lt == n)[0] s = A.ls[faces] if n == 4 and uv is not None: def sa(i, j, k): a, b, c = uv[s + i], uv[s + j], uv[s + k] return (b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1]) - (c[:, 0] - a[:, 0]) * (b[:, 1] - a[:, 1]) d02 = np.minimum(sa(0, 1, 2) * np.sign(sa(0, 2, 3) + sa(0, 1, 2)), sa(0, 2, 3) * np.sign(sa(0, 2, 3) + sa(0, 1, 2))) d13 = np.minimum(sa(1, 2, 3) * np.sign(sa(1, 3, 0) + sa(1, 2, 3)), sa(1, 3, 0) * np.sign(sa(1, 3, 0) + sa(1, 2, 3))) alt = d13 > d02 base = np.where(alt, 1, 0) for k in (1, 2): t0.append(s + base) t1.append(s + (base + k) % 4) t2.append(s + (base + k + 1) % 4) tf.append(faces) continue for k in range(1, n - 1): t0.append(s) t1.append(s + k) t2.append(s + k + 1) tf.append(faces) return np.stack([np.concatenate(t0), np.concatenate(t1), np.concatenate(t2)], 1), np.concatenate(tf) def _raster(tri_uv, tri_label, res, tile=(0, 0)): """Rasterize UV triangles into one tile: coverage count, label map, overlapping label pairs.""" cnt = np.zeros((res, res), dtype=np.uint16) lab = np.full((res, res), -1, dtype=np.int64) pairs = set() P = (tri_uv - np.array(tile, dtype=np.float64)) * res lo = np.floor(P.min(1) - 0.5).astype(np.int64) hi = np.ceil(P.max(1) - 0.5).astype(np.int64) inside_tile = (hi[:, 0] >= 0) & (hi[:, 1] >= 0) & (lo[:, 0] < res) & (lo[:, 1] < res) for t in np.nonzero(inside_tile)[0]: x0, y0 = max(lo[t, 0], 0), max(lo[t, 1], 0) x1, y1 = min(hi[t, 0], res - 1), min(hi[t, 1], res - 1) if x1 < x0 or y1 < y0: continue (ax, ay), (bx, by), (cx, cy) = P[t] d = (bx - ax) * (cy - ay) - (cx - ax) * (by - ay) if abs(d) < 1e-12: continue X, Y = np.meshgrid(np.arange(x0, x1 + 1) + 0.5, np.arange(y0, y1 + 1) + 0.5) w0 = ((bx - X) * (cy - Y) - (cx - X) * (by - Y)) / d w1 = ((cx - X) * (ay - Y) - (ax - X) * (cy - Y)) / d ins = (w0 > 0) & (w1 > 0) & (1 - w0 - w1 > 0) if not ins.any(): continue sub_l = lab[y0:y1 + 1, x0:x1 + 1] other = sub_l[ins] other = other[(other >= 0) & (other != tri_label[t])] if len(other): for o in np.unique(other).tolist(): pairs.add((min(o, int(tri_label[t])), max(o, int(tri_label[t])))) cnt[y0:y1 + 1, x0:x1 + 1][ins] += 1 sub_l[ins] = tri_label[t] return cnt, lab, pairs def _min_gap(lab, cap): """Smallest gap (px) between differently labelled regions, by simultaneous dilation. Returns a value <= 2*cap+1, or None when every gap is larger.""" def touching(L): h = (L[:, 1:] != L[:, :-1]) & (L[:, 1:] >= 0) & (L[:, :-1] >= 0) v = (L[1:, :] != L[:-1, :]) & (L[1:, :] >= 0) & (L[:-1, :] >= 0) return h.any() or v.any() L = lab.copy() if touching(L): return 0 for k in range(1, cap + 1): N = L.copy() for sl_dst, sl_src in (((slice(None), slice(1, None)), (slice(None), slice(None, -1))), ((slice(None), slice(None, -1)), (slice(None), slice(1, None))), ((slice(1, None), slice(None)), (slice(None, -1), slice(None))), ((slice(None, -1), slice(None)), (slice(1, None), slice(None)))): dst, src = N[sl_dst], L[sl_src] m = (dst < 0) & (src >= 0) dst[m] = src[m] if k % 2 == 0: # alternate 4- and 8-neighbourhood: closer to Euclidean growth for sy, sx in ((1, 1), (1, -1), (-1, 1), (-1, -1)): src = np.roll(np.roll(L, sy, 0), sx, 1) m = (N < 0) & (src >= 0) N[m] = src[m] L = N if touching(L): return 2 * k - 1 return None # --------------------------------------------------------------------------- seams def seams_from_sharp(obj, angle_deg=30.0, use_sharp_attr=True, mark_sharp=False, clear_first=False): """Hard-surface rule (Lampel, Gambrell, On Mars 3D): every hard edge is a UV seam. Hard = dihedral angle above angle_deg, or flagged sharp (sharp_edge attribute). mark_sharp=True also flags those edges sharp, so shading splits exactly on seams.""" _object_mode() me = obj.data bm = bmesh.new() bm.from_mesh(me) thr = math.radians(angle_deg) added = 0 for e in bm.edges: if clear_first: e.seam = False if not e.is_manifold: continue hard = e.calc_face_angle(0.0) > thr or (use_sharp_attr and not e.smooth) if hard: if not e.seam: added += 1 e.seam = True if mark_sharp: e.smooth = False bm.to_mesh(me) bm.free() me.update() return {"seams_added": added, "seams_total": sum(e.use_seam for e in me.edges)} def _face_labels(obj, groups): me = obj.data n = len(me.polygons) if isinstance(groups, str): if groups == "MATERIAL": a = np.empty(n, dtype=np.int64) me.polygons.foreach_get("material_index", a) return a name = ".sculpt_face_set" if groups == "FACE_SETS" else groups att = me.attributes.get(name) if att is None or att.domain != "FACE": raise ValueError(f"no face attribute {name!r} on {obj.name}") a = np.empty(n, dtype=np.int64 if att.data_type == "INT" else np.float64) att.data.foreach_get("value", a) return a a = np.asarray(groups) if len(a) != n: raise ValueError("groups needs one label per face") return a def seams_from_groups(obj, groups="FACE_SETS", clear_first=False, faces=None): """Seam every edge between faces of different groups. groups: 'FACE_SETS', 'MATERIAL', a face attribute name, or one label per face (e.g. part ids an agent computed). faces: restrict to edges inside this face subset (e.g. one island that folds).""" _object_mode() me = obj.data lab = _face_labels(obj, groups) sub = None if faces is None else set(int(i) for i in faces) bm = bmesh.new() bm.from_mesh(me) added = 0 for e in bm.edges: if clear_first: e.seam = False if sub is not None and not all(f.index in sub for f in e.link_faces): continue if len(e.link_faces) == 2 and lab[e.link_faces[0].index] != lab[e.link_faces[1].index]: added += int(not e.seam) e.seam = True bm.to_mesh(me) bm.free() me.update() return {"seams_added": added, "groups": int(len(np.unique(lab)))} def seams_from_uv_islands(obj, clear_first=True, uv_map=None): """Lampel: 'Seams from Islands' before re-unwrapping, or the existing layout's islands are thrown away. Also the way to recover real UV seams when the file's seam flags are leftover retopology marks (check with island_topology).""" _object_mode() if clear_first: for e in obj.data.edges: e.use_seam = False with _edit_mode([obj], uv_map): bpy.ops.uv.seams_from_islands(mark_seams=True, mark_sharp=False) return {"seams_total": sum(e.use_seam for e in obj.data.edges)} def _visibility(bm, obj, view_dirs): """Per face: how much it faces the given view directions, graded 0 (pointing straight away) to 1 (facing the viewer), so the most hidden path wins, not any back-facing one.""" N = np.array(obj.matrix_world.to_3x3().inverted().transposed()) D = np.array([Vector(d).normalized() for d in view_dirs]) if view_dirs else np.zeros((0, 3)) vis = np.zeros(len(bm.faces)) if len(D) == 0: return vis fn = np.array([f.normal for f in bm.faces]) @ N.T fn /= np.maximum(np.linalg.norm(fn, axis=1, keepdims=True), 1e-12) return ((fn @ D.T + 1.0) / 2.0).max(1) def _edge_cost(e, vis, floor=0.05): v = np.mean([vis[f.index] for f in e.link_faces]) if e.link_faces else 1.0 return e.calc_length() * (floor + v * v) def _dijkstra(sources, targets, allowed, cost): """Shortest edge path over bm verts. sources/targets: sets of BMVerts; allowed(e) bool.""" dist = {v: 0.0 for v in sources} prev = {} heap = [(0.0, v.index, v) for v in sources] heapq.heapify(heap) done = set() while heap: d, _, v = heapq.heappop(heap) if v in done: continue done.add(v) if v in targets: path = [] while v in prev: e, v = prev[v] path.append(e) return path for e in v.link_edges: if not allowed(e): continue w = e.other_vert(v) nd = d + cost(e) if nd < dist.get(w, float("inf")): dist[w] = nd prev[w] = (e, v) heapq.heappush(heap, (nd, w.index, w)) return None def seam_path(obj, v_from, v_to, view_dirs=((0, -1, 0),)): """Mark the least visible shortest edge path between two vertex indices as a seam (the headless stand-in for Ctrl+click Tag Seam).""" _object_mode() bm = bmesh.new() bm.from_mesh(obj.data) bm.verts.ensure_lookup_table() vis = _visibility(bm, obj, view_dirs) path = _dijkstra({bm.verts[v_from]}, {bm.verts[v_to]}, lambda e: True, lambda e: _edge_cost(e, vis)) for e in path or []: e.seam = True bm.to_mesh(obj.data) bm.free() obj.data.update() return {"edges": len(path or []), "found": path is not None} def _topology(bm, island_faces): """Euler characteristic of the island cut open along its seams, boundary loop count.""" fset = set(island_faces) verts = {v for f in island_faces for v in f.verts} edges = {e for f in island_faces for e in f.edges} def interior(e): lf = e.link_faces return len(lf) == 2 and not e.seam and lf[0] in fset and lf[1] in fset E = sum(1 if interior(e) else sum(1 for f in e.link_faces if f in fset) for e in edges) V = 0 for v in verts: fs = [f for f in v.link_faces if f in fset] par = {f: f for f in fs} def find(x): while par[x] is not x: par[x] = par[par[x]] x = par[x] return x for e in v.link_edges: if interior(e): a, b = e.link_faces par[find(a)] = find(b) V += len({find(f) for f in fs}) chi = V - E + len(island_faces) bedges = [e for e in edges if not interior(e)] par = {} def find2(x): par.setdefault(x, x) while par[x] is not x: par[x] = par[par[x]] x = par[x] return x for e in bedges: a, b = e.verts par[find2(a)] = find2(b) loops = len({find2(v) for e in bedges for v in e.verts}) return chi, loops, bedges, interior def _kind(chi, loops): genus = (2 - chi - loops) // 2 if chi == 1: return "disk", genus if loops == 0: return ("closed" if genus == 0 else "closed_handle"), genus if genus > 0: return "handle", genus return ("ring" if loops == 2 else "holes"), genus def island_topology(obj): """Per seam island: faces, Euler characteristic chi, boundary loops and genus. disk (chi 1) always flattens; ring (2 loops) and holes (3+ loops, genus 0) flatten as a planar shape with holes, fine for a flat washer or a face with eye holes, badly for a long tube (measure it: cut_to_disks does); closed (no boundary) cannot flatten at all; handle / closed_handle (genus > 0, e.g. a torus) need loop cuts.""" _object_mode() labels = _seam_islands(obj) bm = bmesh.new() bm.from_mesh(obj.data) bm.faces.ensure_lookup_table() out = [] for k in range(labels.max() + 1 if len(labels) else 0): faces = [bm.faces[i] for i in np.nonzero(labels == k)[0]] chi, loops, _, _ = _topology(bm, faces) kind, genus = _kind(chi, loops) out.append({"island": k, "faces": len(faces), "chi": chi, "loops": loops, "genus": genus, "kind": kind}) bm.free() return {"islands": len(out), "not_disk": [i for i in out if i["kind"] != "disk"], "must_cut": [i for i in out if i["kind"] in ("closed", "closed_handle", "handle")], "all": out} def _island_quality(obj, face_idx, uv_name): """Unwrap one island alone (Angle Based, fill holes) and measure it; UVs restored.""" me = obj.data old = _get_uv(me, uv_name).copy() fs = set(int(i) for i in face_idx) with _edit_mode([obj], uv_name, select_all=False): bm = bmesh.from_edit_mesh(me) for f in bm.faces: f.select_set(f.index in fs) bmesh.update_edit_mesh(me) bpy.ops.uv.unwrap(method="ANGLE_BASED", fill_holes=True, margin=0.001) A = _Arrays(obj, uv_name) a_uv, a3 = _face_measures(A) fm = np.zeros(A.nf, dtype=bool) fm[list(fs)] = True ang_uv, ok1 = _corner_angles(np.c_[A.uv, np.zeros(A.nl)], A) ang_3d, ok2 = _corner_angles(A.co[A.lv], A) lm = fm[A.lf] & ok1 & ok2 td = np.sqrt(np.abs(a_uv[fm]) / np.maximum(a3[fm], 1e-18)) spread = _wpct(td, a3[fm], 0.95) / max(_wpct(td, a3[fm], 0.05), 1e-12) ang95 = float(np.percentile(np.degrees(np.abs(ang_uv - ang_3d)[lm]), 95)) if lm.any() else 0.0 flipped = int((a_uv[fm] < 0).sum()) A.free() _set_uv(me, old, uv_name) return spread, ang95, flipped def cut_to_disks(obj, view_dirs=((0, -1, 0),), strict=False, max_spread=2.0, max_angle_p95=20.0, max_cuts=200): """Add the least visible seams an island needs to flatten well. Always: closed shells get a slit (Morren: anything closed on itself needs a cut), genus > 0 (torus, handle) gets its loops via a tree-cotree cut. Islands with holes (ring, holes) are test-unwrapped first and cut between two boundaries only while their density spread exceeds max_spread or angle error p95 exceeds max_angle_p95 (a long tube: Gambrell's ring rule; a flat washer or a face with eye holes stays whole). strict=True cuts every island down to a disk. view_dirs: directions toward the viewer (front = (0,-1,0)); seams avoid faces facing them.""" _object_mode() me = obj.data if not me.uv_layers: me.uv_layers.new(name="UVMap") uv_name = me.uv_layers.active.name report = {"cuts": 0, "kept_with_holes": [], "unsolved": []} accepted = set() for _ in range(max_cuts): labels = _seam_islands(obj) bm = bmesh.new() bm.from_mesh(me) bm.faces.ensure_lookup_table() vis = _visibility(bm, obj, view_dirs) cost = lambda e: _edge_cost(e, vis) changed = False for k in range(labels.max() + 1): fidx = np.nonzero(labels == k)[0] key = (len(fidx), int(fidx[0])) if key in accepted: continue faces = [bm.faces[i] for i in fidx] chi, loops, bedges, interior = _topology(bm, faces) kind, genus = _kind(chi, loops) if kind == "disk": continue fverts = {v for f in faces for v in f.verts} path = None if kind in ("ring", "holes"): if not strict: spread, ang95, flipped = _island_quality(obj, fidx, uv_name) if spread <= max_spread and ang95 <= max_angle_p95 and flipped == 0: accepted.add(key) report["kept_with_holes"].append({"faces": len(fidx), "loops": loops, "spread": round(spread, 2), "angle_p95": round(ang95, 1)}) continue par = {} def find(x): par.setdefault(x, x) while par[x] is not x: par[x] = par[par[x]] x = par[x] return x for e in bedges: par[find(e.verts[0])] = find(e.verts[1]) comp = {} for e in bedges: for v in e.verts: comp.setdefault(find(v), set()).add(v) groups = sorted(comp.values(), key=lambda g: (len(g), min(v.index for v in g))) path = _dijkstra(groups[0], set().union(*groups[1:]), interior, cost) elif kind == "closed": vs = sorted(fverts, key=lambda v: v.index) vvis = np.array([np.mean([vis[f.index] for f in v.link_faces]) for v in vs]) a = vs[int(np.argmin(vvis))] low = [v for v, sv in zip(vs, vvis) if sv <= np.median(vvis)] far = max(low, key=lambda v: (v.co - a.co).length) path = _dijkstra({a}, {far}, interior, cost) elif kind == "closed_handle": path = _tree_cotree(bm, faces, fverts, interior, cost, vis) else: report["unsolved"].append({"faces": len(fidx), "chi": chi, "loops": loops, "genus": genus, "hint": "handle on an open island: seam a loop around it"}) accepted.add(key) continue if path: for e in path: e.seam = True report["cuts"] += 1 changed = True break bm.to_mesh(me) bm.free() me.update() if not changed: break report["topology"] = island_topology(obj)["must_cut"] return report def _tree_cotree(bm, faces, fverts, interior, cost, vis): """Cut graph of a closed surface of genus g: 2g loops through the least visible vertex.""" root = min(fverts, key=lambda v: (np.mean([vis[f.index] for f in v.link_faces]), v.index)) dist, prev = {root: 0.0}, {} heap = [(0.0, root.index, root)] done = set() while heap: d, _, v = heapq.heappop(heap) if v in done: continue done.add(v) for e in v.link_edges: if not interior(e): continue w = e.other_vert(v) nd = d + cost(e) if nd < dist.get(w, float("inf")): dist[w], prev[w] = nd, (e, v) heapq.heappush(heap, (nd, w.index, w)) tree = {e for e, _ in prev.values()} fset = set(faces) dual_edges = sorted([e for f in faces for e in f.edges if interior(e) and e not in tree], key=lambda e: -cost(e)) # keep visible edges in the co-tree (uncut) par = {f: f for f in fset} def find(x): while par[x] is not x: par[x] = par[par[x]] x = par[x] return x left = [] for e in dict.fromkeys(dual_edges): a, b = e.link_faces ra, rb = find(a), find(b) if ra is rb: left.append(e) else: par[ra] = rb cut = set() for e in left: cut.add(e) for v in e.verts: while v in prev: pe, v = prev[v] cut.add(pe) return list(cut) # --------------------------------------------------------------------------- unwrap def _self_overlap(uv, T, tf, faces_mask, res=256): """Share of an island's covered pixels covered twice (global fold), island-normalized.""" sel = faces_mask[tf] if not sel.any(): return 0.0 tri = uv[T[sel]] lo = tri.reshape(-1, 2).min(0) span = max(float((tri.reshape(-1, 2).max(0) - lo).max()), 1e-9) cnt, _, _ = _raster((tri - lo) / span * 0.999, np.zeros(len(tri), dtype=np.int64), res) cov = (cnt > 0).sum() return float((cnt > 1).sum()) / max(cov, 1) def _score(a_uv, a3, ang_err, faces_mask, loops_mask): td = np.sqrt(np.abs(a_uv[faces_mask]) / np.maximum(a3[faces_mask], 1e-18)) w = a3[faces_mask] spread = _wpct(td, w, 0.95) / max(_wpct(td, w, 0.05), 1e-12) ang = float(np.degrees(ang_err[loops_mask].mean())) if loops_mask.any() else 0.0 return ang / 5.0 + math.log2(max(spread, 1.0)), spread, ang def unwrap(obj, method="ANGLE_BASED", uv_map=None, faces=None, fill_holes=True, correct_aspect=True, use_subsurf_data=False, iterations=10, weight_group=None, weight_factor=1.0): """Unwrap along the marked seams with an explicit method (5.2 defaults to CONFORMAL). method: 'ANGLE_BASED' (organic default), 'CONFORMAL' (flat hard-surface islands), 'MINIMUM_STRETCH' (most even density), or 'BEST' = all three, kept per island by score = mean angle error / 5 deg + log2(density spread p95/p05). Detects islands the solver silently skipped ('Unwrap failed to solve') and restores their previous UVs. weight_group: vertex group whose weights give more texture space to heavy areas (MINIMUM_STRETCH only; 5.x importance weights). Islands that fold over themselves in every method (a limb flattened onto the body: no flipped face, still an overlap) are returned in folding_islands / folding_faces: add a seam that separates the branch (seams_from_groups(..., faces=folding_faces)) and unwrap again.""" _object_mode() me = obj.data if not me.uv_layers: me.uv_layers.new(name=uv_map or "UVMap") lname = _layer(me, uv_map).name # keep the NAME: layer references go stale after Edit Mode A = _Arrays(obj, lname) fsel = np.ones(A.nf, dtype=bool) if faces is None else np.isin(np.arange(A.nf), np.asarray(faces)) lsel = fsel[A.lf] old = A.uv.copy() pins = _get_pins(me, lname) labels = _seam_islands(obj) methods = ["ANGLE_BASED", "CONFORMAL", "MINIMUM_STRETCH"] if method == "BEST" else [method] results = {} for m in methods: uv = old.copy() uv[lsel & ~pins] = SENTINEL _set_uv(me, uv, lname) with _edit_mode([obj], lname, select_all=False): bm = bmesh.from_edit_mesh(me) for f in bm.faces: f.select_set(bool(fsel[f.index])) bmesh.update_edit_mesh(me) kw = dict(method=m, fill_holes=fill_holes, correct_aspect=correct_aspect, use_subsurf_data=use_subsurf_data, margin_method="SCALED", margin=0.001) if m == "MINIMUM_STRETCH": kw["iterations"] = iterations if weight_group: kw.update(use_weights=True, weight_group=weight_group, weight_factor=weight_factor) r = bpy.ops.uv.unwrap(**kw) new = _get_uv(me, lname) # 5.2.1: an island the solver cannot flatten (closed, no seam) keeps its previous UVs # (here the sentinel, repacked to a zero-area point) while the operator returns FINISHED. A.uv = new a_uv_n, a3_n = _face_measures(A) bad_f = fsel & (a3_n > 1e-14) & (np.abs(a_uv_n) < 1e-14) stuck = (np.all(new == SENTINEL, axis=1) | bad_f[A.lf]) & lsel new[stuck] = old[stuck] results[m] = (new, int(len(np.unique(A.lf[stuck]))), r) # pick per island per_island = {} final = old.copy() A.uv = None uv3 = {} for m, (uv, _, _) in results.items(): A.uv = uv a_uv, a3 = _face_measures(A) ang_uv, ok1 = _corner_angles(np.c_[uv, np.zeros(len(uv))], A) ang_3d, ok2 = _corner_angles(A.co[A.lv], A) uv3[m] = (a_uv, a3, np.where(ok1 & ok2, np.abs(ang_uv - ang_3d), 0.0)) tris = {m: _tris(A, results[m][0]) for m in results} folding = [] for k in np.unique(labels[fsel]): fm = (labels == k) & fsel lm = fm[A.lf] scores = {} for m in results: sc, sp, an = _score(*uv3[m], fm, lm) ov = _self_overlap(results[m][0], *tris[m], fm) scores[m] = (sc + 100.0 * ov, sp, an, ov) best = min(scores, key=lambda m: scores[m][0]) final[lm] = results[best][0][lm] per_island[int(k)] = {m: {"score": round(s[0], 3), "spread": round(s[1], 2), "angle_err_deg": round(s[2], 2), "self_overlap": round(s[3], 4)} for m, s in scores.items()} | {"chosen": best} if scores[best][3] > 0.005: # > 0.5% of the island's pixels: a real fold, not a slit-tip sliver folding.append({"island": int(k), "faces": np.nonzero(fm)[0].tolist(), "self_overlap": round(scores[best][3], 4)}) _set_uv(me, final, lname) if method == "BEST" and len(methods) > 1: # islands from different runs overlap: repack into the tile (real layout comes from pack()) with _edit_mode([obj], lname, select_all=False): bm = bmesh.from_edit_mesh(me) for f in bm.faces: f.select_set(bool(fsel[f.index])) bmesh.update_edit_mesh(me) bpy.ops.uv.average_islands_scale() bpy.ops.uv.pack_islands(rotate=False, margin_method="FRACTION", margin=0.002) A.free() chosen = {} for v in per_island.values(): chosen[v["chosen"]] = chosen.get(v["chosen"], 0) + 1 return {"method": method, "islands": len(per_island), "chosen": chosen, "unsolved_faces": {m: results[m][1] for m in results}, "folding_islands": [{"island": f["island"], "n_faces": len(f["faces"]), "self_overlap": f["self_overlap"]} for f in folding], "folding_faces": [i for f in folding for i in f["faces"]], "per_island": per_island if len(per_island) <= 40 else "omitted (>40 islands)"} def straighten(obj, uv_map=None, min_quad_share=0.9, islands=None): """Lampel/Morren straightening, headless: per grid-like island make its central quad an axis-aligned rectangle, Follow Active Quads over the island's quads, pin them, re-unwrap the rest around the pins, clear the pins. Run BEFORE normalize/pack.""" _object_mode() me = obj.data lname = _layer(me, uv_map).name A = _Arrays(obj, lname) labels, _ = _uv_islands(A) old_pins = _get_pins(me, lname) done = [] todo = range(labels.max() + 1) if islands is None else islands for k in todo: fidx = np.nonzero(labels == k)[0] quads = fidx[A.lt[fidx] == 4] if len(fidx) < 2 or len(quads) / len(fidx) < min_quad_share: continue uv = _get_uv(me, lname) cen = np.array([uv[A.ls[f]:A.ls[f] + 4].mean(0) for f in quads]) mid = uv[np.concatenate([np.arange(A.ls[f], A.ls[f] + A.lt[f]) for f in fidx])].mean(0) act = int(quads[np.argmin(np.linalg.norm(cen - mid, axis=1))]) with _edit_mode([obj], lname, select_all=False): bm = bmesh.from_edit_mesh(me) bm.faces.ensure_lookup_table() ul = bm.loops.layers.uv[lname] f = bm.faces[act] ls = list(f.loops) p = [l[ul].uv.copy() for l in ls] w = ((p[1] - p[0]).length + (p[2] - p[3]).length) / 2 h = ((p[2] - p[1]).length + (p[3] - p[0]).length) / 2 ang = math.atan2((p[1] - p[0]).y, (p[1] - p[0]).x) q = round(ang / (math.pi / 2)) * (math.pi / 2) ex = Vector((math.cos(q), math.sin(q))) ey = Vector((-ex.y, ex.x)) sgn = 1.0 if ((p[1] - p[0]).to_3d().cross((p[3] - p[0]).to_3d())).z >= 0 else -1.0 c = sum(p, Vector((0, 0))) / 4 corners = [c - ex * w / 2 - sgn * ey * h / 2, c + ex * w / 2 - sgn * ey * h / 2, c + ex * w / 2 + sgn * ey * h / 2, c - ex * w / 2 + sgn * ey * h / 2] for l, co in zip(ls, corners): l[ul].uv = co qset = set(quads.tolist()) for g in bm.faces: g.select_set(g.index in qset) bm.faces.active = f bmesh.update_edit_mesh(me) bpy.ops.uv.follow_active_quads(mode="LENGTH_AVERAGE") bm = bmesh.from_edit_mesh(me) bm.faces.ensure_lookup_table() ul = bm.loops.layers.uv[lname] fset = set(fidx.tolist()) for g in bm.faces: inq = g.index in qset for l in g.loops: l[ul].pin_uv = inq or l[ul].pin_uv g.select_set(g.index in fset) bmesh.update_edit_mesh(me) if len(quads) < len(fidx): bpy.ops.uv.unwrap(method="ANGLE_BASED", margin=0.0, fill_holes=True) done.append(int(k)) _set_pins(me, old_pins, lname) A.free() return {"straightened_islands": done} def pin_loops(obj, loops, state=True, uv_map=None): """Pin (or unpin) face corners by loop index. Pinned UVs survive every uv.unwrap.""" _object_mode() p = _get_pins(obj.data, uv_map) p[np.asarray(loops, dtype=np.int64)] = state _set_pins(obj.data, p, uv_map) return {"pinned": int(p.sum())} # --------------------------------------------------------------------------- density def texel_density(obj, tex_res=2048, uv_map=None): """Area-weighted texel density in px/m: whole object, per UV island.""" _object_mode() A = _Arrays(obj, uv_map) labels, _ = _uv_islands(A) a_uv, a3 = _face_measures(A) isl = [] for k in range(labels.max() + 1): m = labels == k isl.append(tex_res * math.sqrt(np.abs(a_uv[m]).sum() / max(a3[m].sum(), 1e-18))) A.free() total = tex_res * math.sqrt(np.abs(a_uv).sum() / max(a3.sum(), 1e-18)) return {"px_per_m": round(total, 1), "px_per_cm": round(total / 100, 2), "islands": [round(v, 1) for v in isl]} def texture_size_for(obj, target_px_per_m, coverage=0.75): """Density first, texture second (Huge Menace): smallest power of two that holds the target at the given UV coverage. res = TD * sqrt(surface m2 / coverage).""" A = _Arrays(obj) P = A.co[A.lv] c = np.cross(P, P[A.nxt]) area = 0.5 * np.linalg.norm(np.stack([np.bincount(A.lf, c[:, k], minlength=A.nf) for k in range(3)], 1), axis=1).sum() A.free() need = target_px_per_m * math.sqrt(area / coverage) return {"surface_m2": round(float(area), 4), "min_res": round(need), "res": int(2 ** math.ceil(math.log2(max(need, 1))))} def face_weights_from_vgroup(obj, group, low=1.0, high=2.0): """Per-face priority weight from a vertex group: weight 0 -> low, 1 -> high.""" vg = obj.vertex_groups[group] w = np.zeros(len(obj.data.vertices)) for v in obj.data.vertices: for g in v.groups: if g.group == vg.index: w[v.index] = g.weight return np.array([low + (high - low) * w[list(p.vertices)].mean() for p in obj.data.polygons]) def normalize_texel_density(obj, target_px_per_m=None, tex_res=2048, weights=None, uv_map=None): """Scale every UV island about its center so its density = target x weight. target None: the current area-weighted median (like Average Islands Scale, but with priorities). weights: one value per face (e.g. face_weights_from_vgroup), island weight = area-weighted mean; face/hands > 1, soles/inner mouth < 1 (Kaspar, SpeedChar). Pack afterwards with scale=False to keep the density exact.""" _object_mode() me = obj.data A = _Arrays(obj, uv_map) labels, _ = _uv_islands(A) a_uv, a3 = _face_measures(A) n = labels.max() + 1 td = np.array([tex_res * math.sqrt(np.abs(a_uv[labels == k]).sum() / max(a3[labels == k].sum(), 1e-18)) for k in range(n)]) area = np.array([a3[labels == k].sum() for k in range(n)]) if target_px_per_m is None: target_px_per_m = _wpct(td, area, 0.5) w = np.ones(n) if weights is not None: fw = np.asarray(weights, dtype=np.float64) w = np.array([np.average(fw[labels == k], weights=np.maximum(a3[labels == k], 1e-18)) for k in range(n)]) uv = A.uv.copy() lab_l = labels[A.lf] for k in range(n): m = lab_l == k s = target_px_per_m * w[k] / max(td[k], 1e-9) c = (uv[m].min(0) + uv[m].max(0)) / 2 uv[m] = c + (uv[m] - c) * s _set_uv(me, uv, uv_map) A.free() return {"target_px_per_m": round(float(target_px_per_m), 1), "before_min_max": [round(float(td.min()), 1), round(float(td.max()), 1)], "islands": int(n), "weights_min_max": [round(float(w.min()), 2), round(float(w.max()), 2)]} # --------------------------------------------------------------------------- pack def pack(objs, tex_res=2048, gap_px=None, rotate_method="CARDINAL", scale=True, shape_method="CONCAVE", merge_overlap=False, udim_source="CLOSEST_UDIM", sweep=True, allow_any=False, uv_map=None, faces=None): """Pack all islands of objs into one shared layout with a PIXEL gap. gap_px: island-to-island gap in texture pixels, default tex_res/128 (Lampel, games; 16 px at 2K, On Mars 3D). Uses margin_method FRACTION, margin = gap/(2*res), which also leaves gap/2 to the tile border. rotate_method CARDINAL keeps straightened islands straight (never ANY after straightening). scale=False keeps texel density exact (reports overflow if the islands do not fit: raise the resolution or split sets). sweep tries CONCAVE/CONVEX and CARDINAL/AXIS_ALIGNED and keeps the best coverage; allow_any adds free rotation (organic islands nobody paints in 2D). faces: pack only these faces of a single object (one UDIM region, one texture set); headless the region lands in 0-1 whatever tile it was in, so pack first, then move_to_tile().""" _object_mode() objs = [objs] if isinstance(objs, bpy.types.Object) else list(objs) gap_px = tex_res / 128.0 if gap_px is None else gap_px margin = gap_px / (2.0 * tex_res) start = {o.name: _get_uv(o.data, uv_map) for o in objs} variants = [(shape_method, rotate_method)] if sweep: rots = [rotate_method] + ([r for r in ("CARDINAL", "AXIS_ALIGNED") if r != rotate_method] if rotate_method in ("CARDINAL", "AXIS_ALIGNED") else []) if allow_any and "ANY" not in rots: rots.append("ANY") for s in ("CONCAVE", "CONVEX"): for r in rots: if (s, r) not in variants: variants.append((s, r)) best = None tried = [] for s, r in variants: for o in objs: _set_uv(o.data, start[o.name], uv_map) with _edit_mode(objs, uv_map, select_all=faces is None): if faces is not None: fs = set(int(i) for i in faces) bm = bmesh.from_edit_mesh(objs[0].data) for f in bm.faces: f.select_set(f.index in fs) bmesh.update_edit_mesh(objs[0].data) bpy.ops.uv.pack_islands(udim_source=udim_source, rotate=r != "NONE", rotate_method=r if r != "NONE" else "CARDINAL", scale=scale, merge_overlap=merge_overlap, margin_method="FRACTION", margin=margin, shape_method=s) cov, over = 0.0, 0 for o in objs: A = _Arrays(o, uv_map) a_uv, _ = _face_measures(A) fm = np.ones(A.nf, dtype=bool) if faces is None else np.isin(np.arange(A.nf), np.asarray(faces)) cov += float(np.abs(a_uv[fm]).sum()) u = A.uv[fm[A.lf]] t = np.floor(u.mean(0)) if faces is not None and len(u) else np.zeros(2) over += int(((u < t - 1e-6) | (u > t + 1 + 1e-6)).any(1).sum()) A.free() tried.append({"shape": s, "rotate": r, "coverage": round(cov, 4), "loops_outside_tile": over}) key = (over == 0, cov) if best is None or key > best[0]: best = (key, s, r, {o.name: _get_uv(o.data, uv_map) for o in objs}) for o in objs: _set_uv(o.data, best[3][o.name], uv_map) return {"coverage": round(best[0][1], 4), "shape": best[1], "rotate": best[2], "gap_px": gap_px, "margin_fraction": round(margin, 6), "fits_tile": best[0][0], "tried": tried} def move_to_tile(obj, faces, tile, uv_map=None): """Move the UVs of these faces into UDIM tile `tile` (1001 + u + 10 v), keeping their position inside the tile (uv.move_on_axis needs a UV editor; this does not).""" _object_mode() A = _Arrays(obj, uv_map) fm = np.isin(A.lf, np.asarray(faces)) uv = A.uv.copy() cur = np.floor(uv[fm].mean(0)) tgt = np.array([(tile - 1001) % 10, (tile - 1001) // 10], dtype=np.float64) uv[fm] += tgt - cur _set_uv(obj.data, uv, uv_map) A.free() return {"tile": tile, "moved_loops": int(fm.sum())} def offset_stacked(obj, du=1.0, uv_map=None, which="FLIPPED"): """Move the mirrored copy of stacked UVs exactly one tile to the right before baking (Polycount; verified: the baker ignores UVs outside a non-tiled image, both halves then read the same texels). which='FLIPPED' moves faces with negative UV area.""" _object_mode() A = _Arrays(obj, uv_map) a_uv, _ = _face_measures(A) m = (a_uv < 0)[A.lf] if which == "FLIPPED" else np.isin(A.lf, np.asarray(which)) uv = A.uv.copy() uv[m, 0] += du _set_uv(obj.data, uv, uv_map) A.free() return {"moved_faces": int((a_uv < 0).sum()) if which == "FLIPPED" else len(which), "du": du} # --------------------------------------------------------------------------- QA def uv_qa(obj, tex_res=2048, uv_map=None, evaluated=False, analysis_res=None, gap_px=None, max_islands_listed=12): """Measure a UV layout the way experts judge it (checker, stretch overlay, packing): islands, stretch (angle and area), texel density per island and its spread, flipped and overlapping faces (raster, per tile), coverage, min island gap and border in texture pixels, tiles used and faces crossing tiles, hard edges not on seams.""" _object_mode() A = _Arrays(obj, uv_map, evaluated) me = A.me labels, cont_edges = _uv_islands(A) a_uv, a3 = _face_measures(A) n_isl = int(labels.max() + 1) if A.nf else 0 valid = (a3 > 1e-14) & (np.abs(a_uv) > 1e-16) td_face = tex_res * np.sqrt(np.abs(a_uv) / np.maximum(a3, 1e-18)) ang_uv, ok1 = _corner_angles(np.c_[A.uv, np.zeros(A.nl)], A) ang_3d, ok2 = _corner_angles(A.co[A.lv], A) ok = ok1 & ok2 aerr = np.degrees(np.abs(ang_uv - ang_3d))[ok] ratio = (np.abs(a_uv) / max(np.abs(a_uv).sum(), 1e-18)) / (a3 / max(a3.sum(), 1e-18)) lr = np.abs(np.log2(np.maximum(ratio[valid], 1e-9))) isl = [] for k in range(n_isl): m = labels == k ta, tu = a3[m].sum(), np.abs(a_uv[m]).sum() fl = int((a_uv[m] < 0).sum()) uvk = A.uv[m[A.lf]] isl.append({"island": k, "faces": int(m.sum()), "td": round(tex_res * math.sqrt(tu / max(ta, 1e-18)), 1), "area_m2": round(float(ta), 5), "uv_px": round(float(tu) * tex_res ** 2), "flipped": fl, "bbox": [round(float(x), 4) for x in (*uvk.min(0), *uvk.max(0))]}) td_isl = np.array([i["td"] for i in isl]) ar_isl = np.array([i["area_m2"] for i in isl]) # tiles cen = np.stack([np.bincount(A.lf, A.uv[:, k], minlength=A.nf) / A.lt for k in range(2)], 1) tile_uv = np.floor(cen + 1e-9) outside = np.any((A.uv < tile_uv[A.lf] - 1e-5) | (A.uv > tile_uv[A.lf] + 1 + 1e-5), axis=1) crossing = int(np.bincount(A.lf, outside, minlength=A.nf).astype(bool).sum()) tiles = sorted({(int(u), int(v)) for u, v in tile_uv}) # raster per tile ares = analysis_res or min(tex_res, 2048) T, tf = _tris(A, A.uv) tri_uv = A.uv[T] tri_lab = labels[tf] tri_tile = tile_uv[tf] per_tile = {} gap_needed = tex_res / 128.0 if gap_px is None else gap_px overlap_px = 0 pairs_all = set() self_overlap = set() min_gap_tex = None for (tu_, tv_) in tiles: sel = (tri_tile[:, 0] == tu_) & (tri_tile[:, 1] == tv_) cnt, lab, pairs = _raster(tri_uv[sel], tri_lab[sel], ares, (tu_, tv_)) cov = float((cnt > 0).mean()) ov = int((cnt > 1).sum()) overlap_px += ov pairs_all |= pairs if ov: ys, xs = np.nonzero(cnt > 1) for lbl in np.unique(lab[ys, xs]).tolist(): if not any(lbl in p for p in pairs): self_overlap.add(int(lbl)) cap = int(math.ceil(gap_needed * ares / tex_res)) + 2 g = _min_gap(lab, cap) g_tex = None if g is None else round(g * tex_res / ares, 1) if g_tex is not None: min_gap_tex = g_tex if min_gap_tex is None else min(min_gap_tex, g_tex) uvt = tri_uv[sel].reshape(-1, 2) - np.array([tu_, tv_]) border = float(np.min(np.minimum(uvt, 1 - uvt))) * tex_res if len(uvt) else None udim = 1001 + tu_ + 10 * tv_ per_tile[udim] = {"coverage": round(cov, 4), "overlap_px_pct": round(100 * ov / ares ** 2, 3), "min_gap_px": g_tex if g_tex is not None else f">{round((2 * cap + 1) * tex_res / ares)}", "border_px": None if border is None else round(border, 1)} # seams vs hard edges sharp = np.zeros(A.ne, dtype=bool) att = me.attributes.get("sharp_edge") if att is not None and att.domain == "EDGE": att.data.foreach_get("value", sharp) cont = np.zeros(A.ne, dtype=bool) cont[cont_edges] = True sharp_not_seam = int((sharp & cont).sum()) flat = me.attributes.get("sharp_face") flat_share = 0.0 if flat is not None: fa = np.zeros(A.nf, dtype=bool) flat.data.foreach_get("value", fa) flat_share = float(fa.mean()) tiny = [i["island"] for i in isl if i["uv_px"] < 16] flipped = int((a_uv < 0).sum()) A.free() order = np.argsort(td_isl) listed = [isl[i] for i in list(order[:max_islands_listed // 2]) + list(order[-max_islands_listed // 2:])] \ if n_isl > max_islands_listed else isl return { "object": obj.name, "uv_map": uv_map or (obj.data.uv_layers.active.name if obj.data.uv_layers else None), "tex_res": tex_res, "faces": int(A.nf), "islands": n_isl, "td_px_per_m": round(_wpct(td_face[valid], a3[valid], 0.5), 1), "td_face_spread_p95_p05": round(_wpct(td_face[valid], a3[valid], 0.95) / max(_wpct(td_face[valid], a3[valid], 0.05), 1e-9), 2), "td_island_spread_p95_p05": round(_wpct(td_isl, ar_isl, 0.95) / max(_wpct(td_isl, ar_isl, 0.05), 1e-9), 2) if n_isl else None, "td_island_min_max": [round(float(td_isl.min()), 1), round(float(td_isl.max()), 1)] if n_isl else None, "angle_err_mean_deg": round(float(aerr.mean()), 2) if len(aerr) else None, "angle_err_p95_deg": round(float(np.percentile(aerr, 95)), 2) if len(aerr) else None, "area_stretch_p95_log2": round(float(np.percentile(lr, 95)), 2) if len(lr) else None, "flipped_faces": flipped, "zero_area_faces": int((~valid).sum()), "overlap_px_pct": round(100 * overlap_px / (ares ** 2 * max(len(tiles), 1)), 3), "overlapping_island_pairs": len(pairs_all), "self_overlapping_islands": sorted(self_overlap)[:20], "tiles": sorted(per_tile), "per_tile": per_tile, "faces_crossing_tiles": crossing, "min_gap_px": min_gap_tex, "gap_required_px": gap_needed, "tiny_islands": tiny[:20], "sharp_edges_not_on_seams": sharp_not_seam, "flat_shaded_share": round(flat_share, 3), "mirror_stacked_hint": bool(flipped and 0.3 < flipped / max(A.nf, 1) < 0.7 and overlap_px > 0), "islands_detail": listed, } def uv_verdict(r, profile="game", priorities=False): """Human-readable problems from uv_qa. profile 'game' (one 0-1 set) or 'film' (UDIM, symmetry allowed). priorities=True accepts deliberate density differences.""" p = [] if r["zero_area_faces"]: p.append(f"{r['zero_area_faces']} faces with zero UV area (unsolved island or collapsed faces)") if r["mirror_stacked_hint"]: p.append("mirror-stacked UVs (half the faces flipped and overlapping): bake would overwrite one side; " "offset_stacked() before baking or give unique UVs where AO differs") elif r["overlap_px_pct"] > 0.05: p.append(f"overlaps: {r['overlap_px_pct']}% of tile pixels, {r['overlapping_island_pairs']} island pairs, " f"self-overlapping islands {r['self_overlapping_islands']}") if r["flipped_faces"] and not r["mirror_stacked_hint"]: p.append(f"{r['flipped_faces']} flipped UV faces") if r["faces_crossing_tiles"]: p.append(f"{r['faces_crossing_tiles']} faces cross a tile border") if profile == "game": if r["tiles"] != [1001]: p.append(f"UVs outside 0-1 (tiles {r['tiles']}) on a single-set game asset") cov = r["per_tile"].get(1001, {}).get("coverage", 0) if cov < 0.70: p.append(f"coverage {cov:.0%} < 70% (Gambrell >70, Huge Menace 75% studio minimum)") if r["min_gap_px"] is not None and r["min_gap_px"] < r["gap_required_px"] - 1: p.append(f"island gap {r['min_gap_px']} px < required {r['gap_required_px']:.0f} px (res/128)") for t, d in r["per_tile"].items(): if d["border_px"] is not None and d["border_px"] < r["gap_required_px"] / 2 - 1: p.append(f"tile {t}: islands {d['border_px']} px from the border (< gap/2)") if not priorities and r["td_island_spread_p95_p05"] and r["td_island_spread_p95_p05"] > 1.25: p.append(f"texel density spread between islands {r['td_island_spread_p95_p05']} (> 1.25; Gambrell: 10-20% is invisible)") if r["angle_err_p95_deg"] and r["angle_err_p95_deg"] > 15: p.append(f"angle stretch p95 {r['angle_err_p95_deg']} deg (> 15: add a seam or relax with pins)") if r["sharp_edges_not_on_seams"]: p.append(f"{r['sharp_edges_not_on_seams']} hard edges inside islands (bake seams): seam them or smooth them") if r["flat_shaded_share"] > 0.5: p.append("mesh is flat shaded: shade smooth (hard edges only on seams) before baking") if r["tiny_islands"]: p.append(f"islands smaller than 4x4 px: {r['tiny_islands']}") return p # --------------------------------------------------------------------------- images and renders def _save_rgba(arr, path, float16=False): h, w = arr.shape[:2] img = bpy.data.images.new("bx_tmp_img", w, h, alpha=True, float_buffer=float16) img.pixels.foreach_set(np.ascontiguousarray(arr, dtype=np.float32).ravel()) img.filepath_raw = path img.file_format = "PNG" img.save() bpy.data.images.remove(img) return path def island_mask(obj, res, uv_map=None, tile=(0, 0)): """Boolean (res, res) mask of pixels covered by UV faces in one tile (rows bottom-up, like Image.pixels).""" A = _Arrays(obj, uv_map) T, tf = _tris(A, A.uv) cnt, _, _ = _raster(A.uv[T], tf, res, tile) A.free() return cnt > 0 def uv_layout_image(obj, path, res=1024, uv_map=None, color="island", tile=(0, 0), wire=True): """PNG of the UV layout (the agent's UV editor): islands in distinct hues or colored by density ('density': blue low, white median, red high) or angle stretch ('angle'), overlaps magenta, island borders dark, UV edges drawn when wire=True.""" A = _Arrays(obj, uv_map) labels, _ = _uv_islands(A) T, tf = _tris(A, A.uv) cnt, lab, _ = _raster(A.uv[T], tf, res, tile) a_uv, a3 = _face_measures(A) img = np.full((res, res, 4), (0.12, 0.12, 0.12, 1.0), dtype=np.float32) covered = lab >= 0 face = lab[covered] if color == "density": td = np.sqrt(np.abs(a_uv) / np.maximum(a3, 1e-18)) med = np.median(td[a3 > 0]) if (a3 > 0).any() else 1.0 x = np.clip(np.log2(np.maximum(td, 1e-12) / med), -1, 1) col = np.where(x[:, None] < 0, (1 + x[:, None]) * np.array([1, 1, 1]) + (-x[:, None]) * np.array([0.1, 0.3, 1.0]), (1 - x[:, None]) * np.array([1, 1, 1]) + x[:, None] * np.array([1.0, 0.15, 0.1])) elif color == "angle": ang_uv, ok1 = _corner_angles(np.c_[A.uv, np.zeros(A.nl)], A) ang_3d, ok2 = _corner_angles(A.co[A.lv], A) e = np.degrees(np.bincount(A.lf, np.where(ok1 & ok2, np.abs(ang_uv - ang_3d), 0), minlength=A.nf) / A.lt) x = np.clip(e / 20.0, 0, 1)[:, None] col = (1 - x) * np.array([0.95, 0.95, 0.95]) + x * np.array([1.0, 0.1, 0.1]) else: hues = (labels * 0.618034) % 1.0 col = np.array([colorsys.hsv_to_rgb(h, 0.55, 0.9) for h in hues]) if len(hues) else np.zeros((0, 3)) img[covered, :3] = col[face] isl = np.where(covered, labels[np.maximum(lab, 0)], -1) edge = np.zeros_like(covered) edge[:, 1:] |= isl[:, 1:] != isl[:, :-1] edge[1:, :] |= isl[1:, :] != isl[:-1, :] img[edge & covered, :3] *= 0.35 if wire: a, b = (A.uv - np.array(tile)) * res, (A.uv[A.nxt] - np.array(tile)) * res n = np.maximum(np.ceil(np.linalg.norm(b - a, axis=1)).astype(np.int64), 1) idx = np.repeat(np.arange(len(a)), n) t = (np.arange(n.sum()) - np.repeat(np.cumsum(n) - n, n)) / np.repeat(n, n) p = a[idx] + (b[idx] - a[idx]) * t[:, None] x, y = p[:, 0].astype(np.int64), p[:, 1].astype(np.int64) ok = (x >= 0) & (y >= 0) & (x < res) & (y < res) img[y[ok], x[ok], :3] *= 0.6 img[cnt > 1, :3] = (1.0, 0.0, 1.0) A.free() return _save_rgba(img, path) def _review_helpers(): import sys here = os.path.dirname(os.path.abspath(__file__)) root = os.path.join(os.path.dirname(os.path.dirname(here)), "scenario-blender-expert", "scripts") if root not in sys.path: sys.path.append(root) import bx_review return bx_review def _render_views(objs_visible, frame_objs, out_dir, prefix, views, res, sc, cam, per_view=None): R = _review_helpers() lo, hi = R._world_bbox(frame_objs) center = (lo + hi) / 2 pts = R._sample_points(frame_objs) paths = [] for view in views: d = R.VIEW_DIRS[view] half, depth = R._extent(pts, center, d) if view in R.PERSPECTIVE: cam.data.type, cam.data.lens = "PERSP", 50 fov = 2 * math.atan(cam.data.sensor_width / (2 * ca
-
-
SKILL.md 16.7 KB
--- name: scenario-blender-uv-baking description: "Use when UV unwrapping, placing seams, straightening or packing UVs, setting texel density, laying out UDIMs or mirrored UVs, or baking normal, AO, curvature, ID or displacement maps from a sculpt or high poly onto a low poly or retopo in Blender, including Multires bakes. Also when a bake has black or inverted patches, flat areas, cracks, seams, wavy lines or AO ghosts, bpy.ops.object.bake returns CANCELED, or uv.unwrap silently fails." license: MIT --- # UV unwrapping and baking Expert-level UVs put seams where the texturing method can afford them, set texel density before texture size, and pack with pixel-true padding. Expert bakes measure their projection distances instead of guessing, and are judged in pixels and under a raking light, never eyeballed once. Every stage below has a measurable gate that [`scripts/bx_uvbake.py`](scripts/bx_uvbake.py) computes. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap. **REQUIRED BACKGROUND:** scenario-blender-expert (execution channel, review loop, 5.2 API changes). ## Stance (the expert delta) - **Seams follow the texturing method.** Kaspar (2021, 2D painting): as few as possible, hidden under hair, clothes and inner limbs. Blender Studio guide (2024, same author): more seams rather than fewer when you paint in 3D or bake, because stretch costs more. Game bakes add a hard rule: every hard edge is a seam, or the normal map cracks (Lampel, Gambrell, On Mars 3D, Polycount). - **Only closed shells and long tubes need extra cuts.** Morren: anything closed on itself needs one cut. Gambrell: cut every taped-paper ring once, hidden. But an island with holes (a face with eye holes, a washer) flattens fine. `cut_to_disks` test-unwraps it and cuts only if it stretches [added; cutting everything folded the sheep's big islands]. - **Unwrap method is a measurement, and 5.2 defaults to CONFORMAL.** Always pass `method=`. On Suzanne, Minimum Stretch gives the most even density (spread 1.38), Angle Based the best angles, and Conformal fails by area (5.89). On flat hard-surface islands all three converge. `unwrap(method="BEST")` picks per island and rejects islands that fold over themselves. - **Density first, texture size second** (Huge Menace). Targets: FPS 10.24 px/cm, weapon 20.48, third person 5.12, top-down 2.56. Priority follows the camera: face, hands and front up; soles, inner mouth and back down (SpeedChar, Kaspar). Match texture sets by scaling the denser set down, never the other up (Gambrell). - **Straighten, then equalize, then pack without tilting** (Lampel): Follow Active Quads from a truly rectangular quad, pin, re-unwrap the rest, pack with CARDINAL. Padding is a pixel number, res/128 (16 px at 2K), passed as FRACTION margin = gap/(2*res). - **Measure the projection, never guess extrusion, and do not trust one global value.** Ryan King's 0.01 then 0.02 was scene-specific. SpeedChar swept six values by hand, and the value that fixed the face broke the armpit ("when we fix something somewhere, some other problems occur", E6QMhGuhyoc [00:26:23]). Rays start on the low, and `max_ray_distance` counts from the extruded start. The adaptive bake keeps, per texel, the smallest extrusion that hits a front face. On the sheep it cut inverted texels from 7.5% to 1.2% [verified]. - **Unique UVs wherever baked lighting differs** (SpeedChar: an AO ghost means shared texels). Stacked or mirrored copies move exactly one tile out of 0-1 before the bake (Polycount), because inside 0-1 the last island baked wins. - **A bake is judged in pixels and under light.** Blue < 0.5 means rays hit the far side. Exactly flat means rays missed. All-white AO means the distance is too small. Inverted pixels that do not move when extrusion changes point to a geometry problem, not a setting [verified on the sheep]. Multires bakes cast no rays: on the same surface they gave 0.09% inverted texels. ## Establish first | Input | Changes | Default when the brief is silent | | ----------------- | -------------------------------------------- | -------------------------------------------------------- | | Target | seams, symmetry, UDIM vs sets, triangulation | game engine, one 0-1 set per material | | Texturing method | seam count and visibility | 3D painting/Substance: seams may show at the back | | Camera | density and priorities | third person 512 px/m, closest view defines tiles | | Texture budget | resolution, sets, UDIM count | 2048 per set; `texture_size_for` decides | | Deformation | seams off joints, tangent-space normals | character: tangent space, unique UVs | | High source | Selected to Active vs Multires | separate sculpt: Selected to Active | | Engine convention | normal green channel | OpenGL (Blender, Unity, glTF); `directx=True` for Unreal | ## Workflow 1. **Pre-flight.** Apply scale (On Mars 3D: it "fixes the vast majority" of problems). Clear non-manifold geometry, doubles and back faces (SpeedChar). Decide on Mirror and Solidify modifiers. If UVs exist, compare `island_topology` with `uv_qa` islands: seam flags in files are often leftover retopology marks, so run `seams_from_uv_islands` (Lampel) before re-unwrapping. GATE: scale 1, `bx_audit` clean, topology table read. 2. **Seams.** Hard surface: `seams_from_sharp(obj, 30, mark_sharp=True)`. Organic: build part labels (head, ears, limbs, torso front and back, soles, inner mouth and eyelids per the Kaspar and Morren seam maps in [`references/expert-notes.md`](references/expert-notes.md)), then `seams_from_groups(obj, labels)`. Specific cuts: `seam_path(obj, v_a, v_b, view_dirs)`. Then `cut_to_disks(obj, view_dirs=[camera])`. GATE: `must_cut` empty; a checker render shows no seams across hero areas that the brief cannot afford. 3. **Unwrap.** `unwrap(obj, method="BEST")`, or the method the island type calls for. GATE: `unsolved_faces` 0 (the operator returns FINISHED even when it fails), `folding_islands` empty (else `seams_from_groups(..., faces=folding_faces)` and unwrap again), angle p95 at most 15 to 20 deg. 4. **Straighten** grid-like islands (hard surface, garments, strands): `straighten(obj)` before any scaling. GATE: straight stripes in the checker. 5. **Texel density.** `normalize_texel_density(obj, target, res, weights)`, then `texture_size_for(obj, target)`. GATE: island spread within 10-20%, or the intended priority ratios. 6. **Pack.** `pack(obj, tex_res=res)`, with `scale=False` for a strict density and `allow_any=True` for organic islands. UDIM: pack each region, then `move_to_tile`. GATE: `uv_qa`: 0 overlaps, gap at least res/128, border at least gap/2, coverage at least 0.70 or an explained exception. 7. **Look.** `uv_layout_image`, `checker_review`, `distortion_review(metric="angle")`. Judge with [`references/critique.md`](references/critique.md). 8. **Bake prep.** `prepare_low(low, triangulate=game, sharp_from_uv_islands=hard_surface)`. Check the silhouette against the high (`bx_audit.audit(low, high=...)`). Explode interpenetrating or moving parts (`explode`, one axis). Run `measure_projection`: `miss_pct` under 2 and `wrong_part_pct` under 1, else use a cage, explode, or bake per pair. 9. **Bake.** `bake_high_to_low(low, highs, out, res=512)` first, then the final resolution. It uses adaptive extrusion by default and hides every other object from render. Multires: `bake_multires`. Extras: `bake_id`, `curvature_from_normal`. GATE: `sanity[...]["problems"]` empty (inverted texels under 2%, notes from 0.2%), 16-bit PNG. If inverted texels remain, run `map_problem_review` to see where. If `sweep_projection` shows they do not move with extrusion, fix the geometry (fit, holes, thin parts), not the numbers. 10. **Review and hook up.** `bake_review(low, highs, paths, out)`: the low with maps must read like the high under the raking light. Then `hookup_maps`. Report measured numbers and what was not verified. ## Numbers | Value | Relative to | Source | | -------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------- | | 10.24 / 20.48 / 5.12 / 2.56 px/cm | FPS world / FPS weapon / third person / top-down | Huge Menace [00:12:28] | | 10-20% density mismatch invisible | between islands or sets | Gambrell [01:36:16] | | coverage above 70%, 75% studio minimum | one 0-1 tile | Gambrell [01:31:56], Huge Menace [00:16:27] | | island gap res/128 (8/16/32 px at 1K/2K/4K) | texture resolution | Lampel [00:38:01], On Mars 3D [00:11:18] | | FRACTION margin m: gap 2m, border m | Blender pack | verified | | bake margin res/128, 16 px at 2K | texture resolution | On Mars 3D, digest | | cylinder sides: 6 min, 8 recommended | low poly silhouette | On Mars 3D [00:03:48] | | samples: 16 for normals, 128 for AO, 1 only while iterating | antialiasing and noise | Gambrell/Marmoset 16, SpeedChar 4x4, verified | | test bake 512, final 2048 | iteration loop | SpeedChar [00:59:09] | | AO distance about 0.2 x bbox diagonal (default 10 units) | asset size | [added], verified effect | | angle error p95 at most 15 deg organic, 3 deg flat | per corner | [added] from 5.2.1 measurements | | Blender packer on an organic character: 55-62% | 16 px gap at 2K | measured on the sheep (several runs) | | inverted texels: single pass 7.3-7.8%, adaptive 2.2% (sculpt) / 1.2% (consistent high) | selected to active, sheep | measured | | 2048 bake, 186k-face high: one pass normal + AO 4.4 s, adaptive 25 s | Metal GPU, headless | measured | ## Quality gates Code: `q = uv_qa(obj, res)`, then `uv_verdict(q, "game" or "film", priorities=...)`. `unwrap()["unsolved_faces"]` and `["folding_islands"]`. `measure_projection()`. `bake_high_to_low()["sanity"]` (inverted, exact-flat, non-unit, AO black and white). Cross-check overlaps with `bpy.ops.uv.select_overlap()` when in doubt; it agreed with the raster on the tests. Visual: layout PNG (islands, magenta overlaps), color-grid checker from front, side, three-quarter and the production camera (squares square, letters upright, seams hidden), angle and density heat maps, `bake_review` sheet (high, low, low + maps), `map_problem_review` when sanity flags pixels. ## Common mistakes | Mistake | Looks like | Fix | | ---------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------ | | `uv.unwrap()` without `method` | Conformal area distortion | pass `method=` or use `unwrap(method="BEST")` | | Trusting FINISHED | closed island keeps its old UVs, repacked | zero the UVs first, check for zero-area faces (`unwrap()` does it) | | Re-unwrapping without Seams from Islands | layout lost; retopology marks used as seams | `seams_from_uv_islands` first | | Cutting every island to a disk | slits, folded islands | measured `cut_to_disks` (default) | | Pack rotation ANY after straightening | tilted grids, jaggy lines | CARDINAL | | Blender margin 0.005 | padding depends on island scale | FRACTION, gap/(2*res) | | Target node active but not selected | bake returns CANCELED, no error | select and activate; assert FINISHED | | Low carries the sculpt as Multires | almost flat normal map | render levels 0 for the bake (bake_high_to_low does it) | | max_ray shorter than extrusion | 90% exactly flat map | ray = extrusion + inward depth | | One global extrusion on thin, close parts (ears on a head) | inverted patches, neighbors baked in | adaptive bake (default), explode, or per-pair bakes | | Other objects render-visible during AO | black AO | hide them from render; the active low never occludes | | Default AO distance 10 | AO all dark or flat | about 0.2 x asset diagonal | | Stacked UVs baked in 0-1 | one side's detail on both | unique UVs or `offset_stacked` | | Hard edge inside an island | crack lines | seam it, or `prepare_low(sharp_from_uv_islands=True)` | | Editing UVs after the bake | seams, tangent mismatch | never; re-bake | ## Blender 5.2 notes - `uv.unwrap` defaults to CONFORMAL. On failure it keeps the island's old UVs and repacks them. It has `use_weights`/`weight_group` (default group "uv_importance"): importance weights with Minimum Stretch. A weighted top half went from 31% to 74% of UV area [verified]. - Sync select is on by default (5.0). `BMLoopUV` only has `uv` and `pin_uv`. A `me.uv_layers[...]` reference goes stale after an Edit Mode toggle, so keep the name. - `pack_islands(udim_source="CLOSEST_UDIM")` headless packed a region sitting in 1002 back into 1001: pack first, then move. `uv.move_on_axis` needs a UV editor. - Bake: the target must be selected AND active (5.0). Operator defaults differ from scene defaults (`use_clear` False vs True, EXTEND vs ADJACENT_FACES): pass everything. Selected to Active uses the low's render evaluation (Multires render_levels, shape keys). Multires settings live in `scene.render.bake.use_multires` with `object.bake_image()`. A float image saved as PNG is 16-bit. Normal Map node `convention` OPENGL/DIRECTX (5.1). UDIM tiles need `image.tile_add(fill=True)`. - Auto Smooth is gone (4.1): smooth shading plus sharp edges; Triangulate "Keep Normals" is `keep_custom_normals`. ## References - `references/expert-notes.md`: judgment by expert with timestamps, disagreements and deciding conditions, my 5.2.1 measurements. Load when choosing seams, density or a bake strategy. - [`references/procedures.md`](references/procedures.md): tested pipelines (hard-surface, character, film/UDIM, Multires, bake-fix loop) and raw bpy snippets executed by the tests. Load before writing code. - `references/critique.md`: rubric for seams, stretch, density, packing, bake setup and baked maps, with proxies and severity. Load at every gate. - [`references/sources.md`](references/sources.md): videos, credentials, URLs, best timestamps, web references. - `scripts/bx_uvbake.py`: the module (docstrings list every function).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.