scenario-blender-texturing-shading
Use when texturing, shading or look-developing in Blender: building a PBR material from a texture set, displacement, procedural materials, edge wear, dirt, grime, color variation, decals, box or triplanar mapping on meshes without UVs, stylized character skin (subsurface, pores),
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-texturing-shading
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
Texturing and shading (Blender 5.2)
Expert shading is layered masks feeding physically plausible values: a clean base (scanned set or flat colors), then contrasty black/white masks that place color, roughness, height and subsurface, all editable in nodes. Values are judged under honest light in both engines, and baking comes last, for portability. 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). Baking maps: scenario-blender-uv-baking. Lighting and final renders: scenario-blender-lighting-rendering.
Toolkit: scripts/bx_materials.py (builders, headless painting, checks; exercised by tests/code/blender-texturing-shading/). sys.path.append("<this skill>/scripts"); import bx_materials as M.
Stance (the expert delta)
- Data maps are Non-Color, "not what looks better, what's correct" (Price). Only base color and emission are sRGB.
- Normal map OR displacement, never both (Price, confirmed by Cycles lead Brecht): they encode the same relief. Displacement when heights will be combined or truly displaced; normal map for export and 8-bit safety.
- Masks, not painted color. Kaspar and Price build every effect as a grayscale mask into a Mix or HSV Factor; color lives in the A/B inputs. Masks need contrast (Color Ramp stops pulled in) or they barely show. The same masks drive roughness, height and SSS.
- Realism: scanned set plus masks; stylized: procedural (Price vs Kaspar and Ryan King). Kaspar strips photos to their pattern (blur plus grain extract) and adds grunge himself, and bakes at the end for portability (export, EEVEE load time), not for Cycles speed.
- Micro detail in roughness, not bump, for skin and hair (Kaspar); edit roughness through HSV Value, never by unplugging the map (Price). Specular stays 0.5. Bump Distance is the real height of the map's 0..1: lower Distance, not Strength (Kaspar).
- Judge honestly: Standard view, neutral mid-gray background, Base Color black to read roughness, a hard backlight and a light from below (Kaspar); grazing light for relief (Price); unlit for hand-painted albedo (Abbitt). EEVEE for iteration, Cycles as the truth, and they must match (Kaspar).
- SSS follows anatomy (Kaspar): high on ears, nose, lids, lips, fingertips; low over bone. Black/white mask, Map Range sets values.
- Bevel, AO, Pointiness and Random Per Island are Cycles-only, and Bevel/AO are stochastic [added, measured]: EEVEE shows no Bevel edges; thresholded live masks give 17 to 18% fractional metallic at any sample count. Bake before thresholding, or use vertex curvature.
Establish first
| Input | Changes | Default when silent |
|---|---|---|
| Target: Cycles still, EEVEE, game engine | live Cycles-only nodes vs baked; AO in albedo (render) vs ORM (game) | Cycles finals, EEVEE side by side |
| Style: realistic or stylized | scanned set + grime vs procedural or hand-painted light | stylized restraint (Kaspar) |
| UVs and deformation | UV images vs box projection vs Object procedurals | rigid, no UVs: box; deforming: UV only |
| Scale | tile size, bump/displacement meters | real meters, scale applied (all experts) |
| Texel budget, export | 2K to 4K hero paint, 4K to 8K large tiling surfaces | 2K hero prop, glTF ORM, OpenGL normals |
Workflow
- Base per part. Prefixed materials, placeholders removed,
diffuse_colorset; values picked on the Diffuse Color pass (matcap made Kaspar's skin too pale). Apply scale. GATE:M.value_report(objs)clean. - Base material. Set:
M.pbr_from_textures(mat, M.find_texture_set(dir), uv_map=...): color spaces by role, AO multiplied at factor 1 (Price: restores the cavity shadow flash capture removes;ao_mode="gltf"for games), bump only without a normal map,height_as="displacement"drops the normal map and sets Cubic interpolation, Displacement and Bump, Scale 0.2 for Poliigon (fixed 20 cm range); add Simple subdivision and shade smooth. UV tile = the set's physical size. Rigid, no UVs:projection="BOX". Procedural: Object coordinates (UV on deforming meshes), Noise Detail at most 5 (cost doubles per level, Price). GATE:M.audit_material(mat, obj)has no errors. - Masks. Kaspar's rule is mask frequency against vertex density: vertex colors for broad soft masks (and Price's environments: tiling UVs repeat image strokes, layouts change), images for fine or exported detail, UV bands for hems. Headless:
M.mask_from_points,M.write_vertex_values,M.smooth_attribute,M.curvature_attribute,M.TexelMap+M.project. Price's Color Burn: soft mask (A) burned by a contrast-ramped height map (B) breaks the mask along bricks or into grout (procedures P18). GATE: render each mask as emission. - Layers.
M.add_wear(mat, edge_color=..., edge_roughness=..., edge_metallic=1)adds variation, cavity/grunge dirt and chipped edges. Blend modes: Multiply darkens (grime, mold), Screen lightens (efflorescence, highlights; Add exceeds 1), Subtract on the height chain for spalling (Price). Chain order sets age (a decal before grime reads old). Decals: Alpha into Factor, Extension Clip (M.add_decal). GATE:M.preview(objs, out, rigs=("studio", "roughness")): effects follow form and water paths, nothing uniform, repeats broken. - Character specifics.
M.stylized_skin(mat, obj=body)(maskscol_brighter/col_darker/col_lips/col_sss/col_rough);M.stylized_eyeper eye object (origin at the eyeball center) plusM.cornea_materialandcornea.visible_shadow = False; hair shine: Anisotropic with a Tangent on a strand-aligned UV map (Cycles only). GATE:rigs=("backlit",): red glow on ears, nostrils, lips, fingers; front not waxy; sclera off-white. - Stylized hand-painted light (Abbitt): lighter top, darker bottom, Multiply in crevices, Screen on extremities. Headless:
M.painted_light(mat, obj)from a curvature attribute, bake Emit, judge withrigs=("unlit",). - Painting, where masks cannot place it. GUI: brushes are assets (
Paint Hard/Soft,Airbrush,Blur,Smear,Fill,Mask); a factory 5.2 session paints BLACK until the brush and unified color are set; strokes project from the screen, so face the surface (Abbitt); Erase needs an alpha channel; bleed 8 to 12 px (Kaspar, Abbitt). UseM.gui_texture_stroke(): it forces a redraw (projection painting occludes with the depth buffer of the previous draw: a stroke issued in the setup call painted 162 texels in one run instead of about 14,000, full coverage in another) and resamples dabs. Headless:M.TexelMap+M.paint_strokepaints by world distance, seams included;M.dilate, thenM.save_image(images are never saved with the .blend by a script). GATE: renders from views crossing seams. - Lookdev check and bake.
M.preview(EEVEE and Cycles, studio, backlit, roughness),M.value_report, labeled frames and node groups (Kaspar, Price). Bake procedural channels via Emission, 30+ samples, validate by rebuilding from the maps (scenario-blender-uv-baking).
Numbers
| Value | Number | Source |
|---|---|---|
| Dielectric albedo / metal reflectance | sRGB about 30 to 240 / 180 to 255; metallic 0 or 1 | ranges [added]; binary metallic: Ryan King, Price |
| Skin roughness | 0.68 to 0.72, rough zones 0.72 to 1, lips and mouth about 0.4 | Kaspar |
| Pores | Voronoi Smooth F1 scales 100 and 200, noise mix 0.2 to 0.25 | Kaspar, on his UVs |
| Skin SSS scale (5.2) | 0.005 to 0.04 m | measured on a 0.33 m head; Kaspar's v1 0.02 to 0.15 is waxy in 5.2 |
| Bump | Distance about 0.001, Strength 0.1 to 0.2 for fabric | Kaspar |
| Displacement | Scale 0.2 (Poliigon), Midlevel 0.5, Cubic, 16-bit source | Price |
| Brick wall roughness | about 0.7 | Price |
| Color tweak | Hue within 0.5 plus or minus 0.04 | Price |
| Noise Detail | 5 or less | Price |
| Edge-wear Bevel radius | 0.04 on a 1 m crate | measured |
| Paint bleed / island margin | 8 to 12 px / 0.01 to 0.05 | Kaspar, Abbitt |
| Painted height | 2K, 32-bit float, base 0.5, Non-Color | Kaspar; 0.5 gray base also Ryan King |
| Bake samples | 30 minimum | Kaspar |
Quality gates
Measurable:
M.audit_material(mat, obj): color spaces by role, no image straight into Normal, explicit UV maps, Bump Distance at most 0.05, no Add Shader or raw summed normals, Emission Strength set, no gray constant metallic, normal map plus displacement flagged, non-Cubic displacement image, Noise Detail above 6, unsaved images.M.value_report(objs): dielectric albedo within 30 to 243 sRGB (under 2% outside), metal above 180, fractional metallic under 5% (live Bevel/AO wear exceeds it until baked), roughness std above 0.01.- Scale applied; color attributes named
col_*and matching every Attribute node exactly (Kaspar, Price).
Visual: M.preview EEVEE vs Cycles nearly identical; roughness row shows breakup; backlit glow only where flesh is thin; grazing light shows relief without terracing; repeats broken at distance; per-layer mute renders each add something (Price); scale against a human reference.
Common mistakes
| Mistake | Looks like | Fix |
|---|---|---|
| Data map left sRGB | bent bump, wrong gloss | Non-Color |
| Normal map and displacement together (Node Wrangler default) | doubled or broken relief | keep one |
| Displacement with no geometry or Catmull-Clark | nothing, or rounded corners | Simple subdivision, shade smooth |
| Pre-4.5 Bump Distance 1.0 | stepped, plastic | real height, about 0.001 to 0.01 |
| Emission Color set, Strength 0 | no glow | Strength above 0 |
| Kaspar's v1 SSS values in 5.2 | waxy white ears | 0.005 to 0.04, check backlit |
| Bevel wear or Pointiness in EEVEE or games | wear missing | bake, or curvature_attribute |
| Threshold on live Bevel/AO for metallic | grainy gray borders | bake, then threshold |
| Cornea shell in EEVEE, default thickness | iris magnified | Thickness 0 (cornea_material) |
| Mask straight from Noise | effect barely visible | ramp or Map Range for contrast |
| Stroke right after setup | partial or no paint, varies per run | redraw first (gui_texture_stroke) |
| Paint color never set / new color attribute | black strokes / white mask | set colors; write zeros first |
| Box projection or Generated coords on a rig | texture swims | UV coordinates |
Blender 5.2 notes
- Principled v2: Subsurface Weight/Scale (no Subsurface Color), Specular IOR Level, Transmission Weight, Coat, Emission Strength default 0. Sockets by name; Mix node by index (RGBA: A=6, B=7, Result=2).
- Color spaces
sRGB,Non-Color,Linear Rec.709. Displacement node Scale default 0.01;material.displacement_method(notmaterial.cycles); EEVEE displaces since 4.2 (Displacement and Bump). use_nodesdeprecated;surface_render_methodreplacesblend_method; refraction needsuse_raytrace_refractionplus scene ray tracing.- EEVEE ignores anisotropy, Bevel, Pointiness, Random Per Island (verified). Random Walk SSS rescaled in 5.2. Musgrave is inside Noise.
- Brushes are assets; custom brushes need "Save Changes to Asset" (Abbitt);
image_brush_type; Blur isSOFTEN; sample colorShift+X. paint.vertex_color_dirtworks headless in Object mode; byte images store encoded values, floatLinear Rec.709images convert to sRGB when saved as PNG.
References
references/procedures.md: tested code for every stage (texture sets, displacement rule, box projection, wear, curvature, decals, skin, eyes, anisotropy, GUI and headless painting, high-pass, checks, camera-ray world, clay switch, painted light, Color Burn masks, physical UV scale).references/expert-notes.md: reasoning per expert with timestamps and the disagreements with their deciding conditions.references/critique.md: rubric for judging your own renders and node trees.references/sources.md: every source video, credential, best timestamps.
Files (skills)
-
references
-
critique.md 5.7 KB
# Critique rubric: judging your own materials Use after every stage gate. Render first (`M.preview(objs, out, rigs=("studio", "backlit", "roughness"))`, EEVEE and Cycles), open the sheet, then walk the list. Each item says what an expert looks at and how to check it in code where possible. Score each section pass / fix; fix before moving on. ## 1. Values (before anything else) - Albedo reads as a plausible surface color on the Diffuse Color pass with Standard view, not lit color (Kaspar). Code: `M.value_report(objs)["albedo_srgb_max_channel"]`; dielectrics roughly sRGB 30 to 240, metals 180 to 255 [added, Substance PBR guide]. - No pure white and no pure black surfaces; sclera and teeth off-white (Kaspar, kAdgrBS4eDA [01:36:52]). - Metallic is a mask: 0 or 1, fractional only in transitions (Ryan King, Price: delete an all-black metallic map). Code: `metallic_grey_fraction` under 0.05 (live Bevel/AO wear exceeds this until baked). - Scanned sets: AO multiplied into base color unless true displacement is used; albedo otherwise free of baked lighting (Price). Stylized unlit art: light painted in on purpose, and it reads with no lights (`rigs=("unlit",)`, Abbitt). - Skin hue leans red, not orange; darker skin lightens toward light brown around brows, cheeks and nose, lips darker and more saturated (Kaspar, 0vGNOtX9iPk [01:01:46], [01:23:46]). ## 2. Roughness (the map that sells the surface; framing [added]: Kaspar puts micro detail here, Price varies it everywhere) - In the `roughness` rig (Base Color black), the surface still tells its story: breakup, zones, wear smoother or rougher than the base. Uniform roughness reads CG. Code: `roughness_std` above 0.01. - Skin: forehead, nose, cheeks and lids glossier; chin and brows rough; lips glossy; palms and fingertips not sweaty; nails and fingertips consistent (Kaspar). - Pores readable up close, invisible noise at mid distance (Kaspar, D7kuKx7BV1A). - Worn metal edges smoother than the paint; dirt rougher than the base. ## 3. Masks and detail placement - Every variation follows form: cheek highlight follows the cheek, wear on convex edges, dirt in cavities and on up-facing surfaces, streaks run down (Kaspar, 0vGNOtX9iPk [01:36:46]); grime follows water paths, corners and openings, in big, medium and small shapes, checked against a photo reference (Price). - Masks reach 0 and 1 (contrast through a ramp); a mask that stays mid-gray does nothing (Price, Ryan King). - Tiling breaks up at viewing distance: no visible repeat (Price's sun-fade and grime masks). - Each layer earns its place: mute effect groups one at a time (`node.mute = True`) and compare renders (Price). - Chosen restraint: variation where it adds definition, nothing where it distracts ("they all choose very well where to put the colors and where to leave them out", 0vGNOtX9iPk [01:35:31]). - Crossing masks do not leave holes; creases darken, never lighten (Kaspar, uTjfRGGV6ys). - Painted contributions blend in at distance and do not read as strokes (Kaspar, uTjfRGGV6ys [01:52:08]). - No visible UV seams in painted or baked maps: render views that cross seams (`test_uvpaint.py` style). ## 4. Bump and normals - Bump subtle, not stepped, not plastic; Distance in real units (Kaspar). Code: `audit_material` warnings. - Normals combined by chaining Bump nodes or Add + Normalize, never raw Add (Kaspar). Code: `audit_material` error. - Normal maps through a Normal Map node, Non-Color, correct green convention (OpenGL in Blender; DirectX sets `convention='DIRECTX'`). - Never a normal map and true displacement from the same set (Price; `audit_material` error). Displacement: Simple subdivision, shade smooth, Cubic height, Scale set deliberately (5.2 default 0.01; Poliigon 0.2); grazing-light render shows relief at the silhouette without terracing. - Modeled wrinkles are not re-enhanced in the shader (Kaspar, uTjfRGGV6ys [01:28:51]). ## 5. Light response - Backlit: ears, nostrils, lids, lips and fingers glow red; forehead, jaw and palm center stay opaque; frontal view not waxy (Kaspar, D7kuKx7BV1A). - From below and hard backlight: no surprises (bump artifacts, unexpected shine). - Hair: streaky, stylized highlights, less shine at the silhouette (Kaspar, 8GD1C9qLi-4). - Eyes: pupil centered from the side through the cornea, shimmer on the lower iris, highlight small, sclera not blown out (Kaspar). ## 6. Engines and portability - EEVEE and Cycles nearly identical side by side (Kaspar). If not: Bevel, Pointiness, anisotropy or AO in the tree (Cycles-only or different in EEVEE), EEVEE refraction thickness, SSS differences. - Anything a game engine or EEVEE needs is baked; baked material rebuilt from the maps matches the procedural one except resolution (Kaspar, EDEMbeZfVcc [00:46:36]). - Color spaces right on every image; images saved (`image.is_dirty` False); glTF export carries base color, metallic-roughness, normal and (if wanted) occlusion. ## 7. Node tree hygiene - Frames and labels per channel (base color, SSS, roughness, bump), shared masks as node groups placed next to consumers, named reroutes; someone else can read it (Kaspar, EDEMbeZfVcc [00:06:04]; Price: label as you build). - Noise Detail at most 5 or 6 (Price: cost doubles per level); scale applied on objects using Object coordinates or physical UV scale; texture scale checked against a human reference (Price). - Materials named with the asset prefix; no leftover placeholder or grid materials; `diffuse_color` set for solid view. - Every image node has an explicit UV map or vector input (Kaspar, kN5aq1qXUCE [02:16:08]). ## Report format State per section: pass or the fix applied, with the metric (value_report numbers, audit findings) and the sheet path you looked at. Say what was not verified (for example "EEVEE not checked", "no backlit render"). -
expert-notes.md 27.4 KB
# Expert notes: texturing and shading The judgment behind SKILL.md, by expert, with video id and timestamp (`[hh:mm:ss]`). Video ids resolve to `https://www.youtube.com/watch?v=<id>`; details in `sources.md`. [added] marks the skill author's own additions; "measured" marks facts established by the tests in `tests/code/blender-texturing-shading/`. --- ## Julien Kaspar (Blender Studio), Snow stylized character shading lives, 2021 Eight livestreams, Blender 3.0 alpha, Principled BSDF v1 and EEVEE Legacy. One expert, so "consensus" below means convictions he repeats across sessions. Values quoted are his; version translations are marked. ### Method: layered masks in nodes - Flat base colors per material first, then black/white masks mixed in nodes, "very non-destructive, like Substance" (0vGNOtX9iPk [00:05:14], [02:04:38]). Masks are later reused for roughness, SSS and bump (0vGNOtX9iPk [02:05:50]). - Masks gate procedural textures rather than tint: "not just adding color, but adding textures via masks" (kN5aq1qXUCE [01:03:05]). - Start skin darker and build value up by painting brightness in (0vGNOtX9iPk [00:18:50]). - Lips mask inverted and multiplied into the brighter/darker masks so brightening never touches the lips (0vGNOtX9iPk [01:04:32], [01:20:46]). - Vertex colors vs texture paint: vertex colors for broad soft masks on a dense retopo (fast, blur works, no UVs) (0vGNOtX9iPk [01:19:25]; D7kuKx7BV1A [00:21:38]); texture paint when vertex resolution shows (hands) or for height and streaks (kN5aq1qXUCE [00:58:32]; uTjfRGGV6ys [00:55:37]). Deciding condition: mask frequency against vertex density. - Color attributes are per face corner: face masking gives hard borders (nails), vertex masking soft ones (0vGNOtX9iPk [02:06:31]). - Name color attributes `col_*`: attribute pickers mix them up with UV maps (kN5aq1qXUCE [01:34:39]). - Keep the whole character at a consistent level of progress, not the skin finished first (kN5aq1qXUCE [01:08:24]). - Organize as you go: frames, labels, color codes, node groups, named reroutes; a shared mask becomes a node group duplicated next to each consumer instead of long crossing links (kN5aq1qXUCE [00:42:16]; EDEMbeZfVcc [00:07:09], [00:08:17]). ### Judging values - Diffuse Color view with the Standard view transform: "whatever is a value of one, I want that to be white" (0vGNOtX9iPk [00:09:32]; kN5aq1qXUCE [00:45:55]). In 5.2 the default is AgX, Standard still exists. - Matcap sculpting made his characters too pale, darker skin tones especially (0vGNOtX9iPk [00:07:51]). - Neutral mid-gray non-gradient background; an HDRI like the forest one tints everything green (0vGNOtX9iPk [00:13:20], [01:58:52]). - Isolate channels by unplugging or muting links: base color to black to read bump and reflections, normal off to read roughness (kN5aq1qXUCE [01:26:09]; D7kuKx7BV1A [01:19:36]). `M.preview(rigs=("roughness",))` automates this. - Test under many lights, including a light from below that most HDRIs cannot give (D7kuKx7BV1A [00:28:31], [00:40:50]); a strong backlight reveals bump too (uTjfRGGV6ys [01:43:52]). - EEVEE for iteration (viewport samples 1 while painting), Cycles as the reference; Cycles exposed an unnormalized-normal bug EEVEE hid (uTjfRGGV6ys [01:21:33], [01:25:29]; D7kuKx7BV1A [00:39:44]). - EEVEE loses fine texture detail when zoomed out; judge fine texture at working distance (kAdgrBS4eDA [00:25:37]). ### Skin (Snow #6 is the key session) - SSS makes stylized skin read as skin; its amount follows anatomy: "anything where the light passing through would be obscured by bone, less light would actually come through" (D7kuKx7BV1A [00:20:31]). High: earlobe, tragus, nose wings and tip, eyelids, lips inside and out, fingers and fingertips, thumb pad. Low: brows, forehead, cheekbones, jaw, palm center, back of hand (D7kuKx7BV1A [00:12:43], [00:38:36], [00:43:05]). - Mask pure black/white, Map Range sets the actual values: min 0.02, max 0.06 then about 0.15 (v1 "Subsurface") (D7kuKx7BV1A [00:16:39], [00:40:17]). Translation: in v1 that input also multiplied the radius, which is Subsurface Scale in v2. Measured in 5.2 (Random Walk rescaled): 0.02 to 0.15 turned a 0.33 m head's ears white-pink and waxy under a backlight; 0.005 to 0.04 gave the red glow he describes (`test_skin.py`, sss sheets). - Increasing SSS makes skin waxy; use it as variation (D7kuKx7BV1A [00:09:49], [00:44:14]). A small SSS value costs about as much render time as a large one (D7kuKx7BV1A [00:51:34]). - Backlit test rig: rotate the HDRI to backlight, add area lights straight behind the head at power 50, plus a light from below (D7kuKx7BV1A [00:14:27], [00:16:06], [00:40:50]). Ears, lips and fingers should glow red, the frontal view should not look waxy (D7kuKx7BV1A [00:17:13]). - SSS color inherited the darkening masks of the base color network and darkened the upper lip; he duplicated the network into its own "SSS" frame (D7kuKx7BV1A [00:24:31]). 5.2 has no Subsurface Color: the SSS albedo is Base Color; redden via Subsurface Radius or a separate Subsurface Scattering node [added]. - Fact-check the SSS color against subdermal references: redder than the surface (D7kuKx7BV1A [00:10:21]). - Pores in roughness, not bump: "I want that to be not in the bump for extra skin detail, but in the roughness" (D7kuKx7BV1A [01:00:38]). Voronoi Smooth F1 (no cell cracks), scales 100 and 200 (first tries 250) mixed by a detail-0 noise, general noise mixed at 0.2 to 0.25; pores slightly glossier, cracks rougher (D7kuKx7BV1A [01:01:10], [01:06:38], [01:09:10], [01:20:47]). - Base skin roughness 0.68 then 0.72 (D7kuKx7BV1A [01:18:28]); a later check found skin too wet at 0.44 and raised it (8GD1C9qLi-4 [02:13:18]). Rough mask remapped to 0.72 to 1 (D7kuKx7BV1A [01:24:15]). - Face roughness zoning: glossier forehead, nose, cheeks, under the eyes and lids, a bit on the ear; lips and inner mouth fully glossy; shaved chin rough; brows not glossy (D7kuKx7BV1A [01:26:22], [01:31:20]). Check palms and fingertips: they easily go sweaty; shiny fingertips next to rough nails make no sense (8GD1C9qLi-4 [02:15:32], [02:25:33]). - Wet mouth without touching specular: keep 0.5, tongue roughness about 0.4 with strong waxy SSS, teeth roughness 0.4, slight SSS, cream color (D7kuKx7BV1A [00:50:05], [00:50:52]). - Paint region masks as black/white plus Map Range, not absolute painted values that get color managed (D7kuKx7BV1A [01:21:56]). - Stylized restraint: no wrinkles or realistic imperfections, but "the details that are there should be a reflection of how the real materials behave" (D7kuKx7BV1A [00:29:03]). - Stubble and hairline: keep flat, gradient to skin color bottom to top, a little detail for directional shine, a touch of SSS to blend into flesh (8GD1C9qLi-4 [02:02:25], [02:07:46], [02:11:20]). ### Eyes (Snow #5) - Procedural rather than painted; Rain's painted iris looked worse and was less adjustable (kAdgrBS4eDA [01:04:50]). - One object per eye with its origin at the eyeball center, mirror modifier removed, stray keys cleared; object-space gradients break if the eye is stretched in edit mode (kAdgrBS4eDA [01:12:07], [01:17:07], [01:25:07]). - Spherical gradient in object space, two Color Ramps for iris and pupil, a Linear Light mix with noise to break the circles without moving them, darker limbal rim via Multiply (kAdgrBS4eDA [01:10:25], [01:19:30], [01:21:40]). Implementation note [added]: a gradient centered on the eyeball center is constant over the eyeball surface, so `stylized_eye` measures distance from the front pole instead. - Never pure white sclera: "just like there is no 100% black anywhere on any surface, there's no 100% white"; tint toward flesh at the edges (kAdgrBS4eDA [01:22:52], [01:36:52]). - Iris streaks from a Voronoi stretched along one axis (kAdgrBS4eDA [01:30:35]); `stylized_eye` builds radial streaks from polar coordinates [added]. - Iris: gray with a hint of blue, too saturated or green reads creepy (kAdgrBS4eDA [01:57:47]); later darker, deeper blue and metallic for the shine of a real iris (D7kuKx7BV1A [00:00:35]). - Deepen the iris indentation so the refracted shimmer catches light only on the lower iris (D7kuKx7BV1A [00:01:52]); refraction re-spheres the concave iris, IOR matters, depth barely (kAdgrBS4eDA [01:43:13]). - Cornea: Transmission 1, low roughness; he used Glossy + Refraction through a Mix Shader because v1 specular would not go low enough, and Add Shader is not physically correct (kAdgrBS4eDA [01:41:28], [01:44:49]). Cornea casts no shadow (kAdgrBS4eDA [01:42:39]). Pupil mask into specular so the pupil has no highlight (kAdgrBS4eDA [01:46:40]). - 5.2 translation, measured: EEVEE needs Material Output Thickness 0 on the cornea shell or it magnifies the iris across the eye; `visible_shadow = False` is honored by EEVEE and Cycles. - Scale the pupil to its neutral size (about 0.7) before baking so a dilation shape key does not stretch the bake (kAdgrBS4eDA [01:48:26], [01:50:38]). - Subtle bump from the gradient through Map Range (Color Ramps clip) at strength 0.1 (kAdgrBS4eDA [01:53:47], [01:55:34]). ### Cloth, seams, painted height - Photo textures contribute only their structural pattern: blur until the weave disappears, Grain Extract against the original, keep the difference as a mask (rKVeLjxZzTc [00:02:47], [00:04:03]). `M.high_pass` is the numpy equivalent. - Use a color map only as pattern: saturation 0, value lowered, multiplied into the flat color (kN5aq1qXUCE [01:17:00]). - Bump: lower Distance (about 0.001), Strength 0.1 to 0.2 for fabric (kN5aq1qXUCE [01:23:24]); displacement maps Non-Color (kN5aq1qXUCE [01:30:28]). - Straightened UV strips as coordinate systems: Separate XYZ on a strip UV gives band masks for hems (Greater Than hard, Map Range soft), and a second texture branch on a rotated UV map turns the weave on the hem (kN5aq1qXUCE [01:45:30], [01:56:20]). - Stitches: a stitch-row texture at the bottom of the UV tile with strips laid flat on it; consistent texel density across strips or stitch size varies (8GD1C9qLi-4 [00:13:16]; rKVeLjxZzTc [01:36:02]). - Crossing masks multiply into holes: process lines separately and combine after; creases multiply darker, never lighten (uTjfRGGV6ys [00:48:52], [00:49:27]). - Painted height: 2K, 32-bit float, base 0.5, Non-Color (he forgot, chat caught it); non-destructive but clips at the texture range and never changes the silhouette (uTjfRGGV6ys [00:55:37], [01:39:56], [01:44:30]). Do not re-sculpt modeled wrinkles in the shader (uTjfRGGV6ys [01:28:51]). - Combining bumps: Vector Math Add then Normalize fixed a wrong glossy look in Cycles (uTjfRGGV6ys [01:24:56], [01:25:29]); chaining Bump nodes is the cleaner standard [added], and what `pbr_from_textures` does. - One painted 0.5-based color-variation map split by Color Ramps into a bright (Add wash) and a dark (Multiply) mask; height times fabric structure through a high-contrast ramp gives acid wash on raised areas only (rKVeLjxZzTc [02:04:12]; kAdgrBS4eDA [00:24:56]). - Keep clothing simple: some wear and variation, never realistic or distracting (rKVeLjxZzTc [01:58:09]). - UVs outside 0..1 are wrong for painting and tileable images, fine as procedural coordinates (rKVeLjxZzTc [00:57:07]). - Custom logo: a "logo" UV map projected from view on the front faces, all other UVs scaled to zero in a corner (EDEMbeZfVcc [00:50:36]). `M.add_decal` parks other loops outside 0..1 with Extension CLIP instead. ### Hair shading - Stylized hair: extra detail without realism, no bump or strand detail, "a bit brush-strokey" (8GD1C9qLi-4 [00:56:53]). Micro detail in roughness (D7kuKx7BV1A [01:59:21]). - Real anisotropy in Cycles from a Tangent node on a strand-aligned UV map, streak texture into Anisotropic Rotation in a 0 to 0.5 range; fake anisotropy node group for EEVEE; separate EEVEE and Cycles material outputs (D7kuKx7BV1A [01:55:27], [02:13:27]; 8GD1C9qLi-4 [01:09:34], [01:13:56]). Measured: EEVEE 5.2 still renders Principled anisotropy isotropic. - Fresnel through Map Range (not Color Ramp) added to roughness so hair is less shiny at the silhouette; clamp or the shader breaks (8GD1C9qLi-4 [00:52:07], [00:54:13]). - Through-the-clump streak painting: Normal falloff, Occlude and Backface Culling off so one stroke passes through the whole object (8GD1C9qLi-4 [00:47:42]). Measured in the GUI test: equal paint on front and back. ### Baking, presentation, housekeeping - Procedural is fine for Cycles (Sprite Fright mostly unbaked); bake for portability and for EEVEE load time; ship the asset baked, keep procedural networks separately (EDEMbeZfVcc [00:04:55], [00:48:13], [00:53:04]). Bake is flattening a layer stack (EDEMbeZfVcc [00:38:16]). - Bake recipe: Emit via a Node Wrangler preview, margin 8 px, 30+ samples (1 only for flat color, "one sample is not a good idea for normal maps"), disable the Mask modifier's render visibility, validate by rebuilding the material from the maps (EDEMbeZfVcc [00:31:58], [01:21:12], [01:36:11], [00:45:28]). Details in scenario-blender-uv-baking. - Stylized diffuse maps may carry a bit of AO or lighting; he does not strictly separate albedo from diffuse (EDEMbeZfVcc [01:34:26]). - Global clay and wire switch: a node group in every material driven by one custom property (clay: diffuse 0.6 gray, SSS 0, roughness 0.6) (EDEMbeZfVcc [00:15:26], [00:17:04]). - Camera-ray world: camera sees flat gray, the HDRI lights the character; "a really neat trick that I'm basically always using" (8GD1C9qLi-4 [00:35:14], [00:35:50]). - Autosave does not save painted images: save or pack them (8GD1C9qLi-4 [01:22:36]). 5.2 release notes say autosave now covers texture paint; a headless script still has to save explicitly. - Viewport display colors: pick each material's diffuse color into `diffuse_color` for animators (kAdgrBS4eDA [01:05:09]). - UDIMs are little used at the studio because procedural detail plus low-resolution masks suffice (kAdgrBS4eDA [00:11:43]); used on Snow's body for export (EDEMbeZfVcc [00:54:33]). --- ## Measured facts added by this skill (5.2.1) - EEVEE: Bevel node gives no edges (Edges mask mean 0.0016 vs 0.111 in Cycles); AO node works but differs (screen space); Principled anisotropy ignored; Geometry Pointiness and Random Per Island constant (std 0.001 and 0.0 vs 0.013 and 0.288 in Cycles, `tests/code/blender-texturing-shading/probes/probe_pointiness.py`, `probe_island.py`); box projection matches Cycles; Grunge/Variation noise identical. - Bevel/AO masks are stochastic per sample: thresholded metallic 0% fractional at 1 spp, 17 to 18% at 16 and 128 spp. - Noise Texture Fac clusters in about 0.3 to 0.7; stretch before using as breakup. - Cornea shell needs Thickness 0 in EEVEE; raytrace refraction off hides the eye. - Texture paint: strokes issued before a redraw are occluded by the previous draw's depth buffer (162 texels in one run, full coverage in another; about 14,000 after a redraw every run); scripted strokes place one dab per point; factory paint color black. - New FLOAT_COLOR attributes start white (1, 1, 1, 1). - `bmesh.ops.create_uvsphere(calc_uvs=True)` writes UVs only into an existing UV layer. ## Andrew Price (Blender Guru, founder of Poliigon): "How to Texture in Blender", 2024 1h53 course on Blender 4.2, frames checked by the distiller. The most complete realistic workflow in the sources. Video uHCJoNEWjXo. ### Stance - Production realism = scanned PBR set plus procedural and painted masks on top; hundred-node full-procedural materials are a game-pipeline niche, "almost never done" in VFX or animation [00:08:02]. Deciding condition against Kaspar and Ryan King: realistic hero surfaces take scans; stylized, file-free or endlessly variable assets go procedural. - Every map but base color is data, Non-Color: "it's not about what looks better or what you can see, it's what's actually correct" [00:21:29], [00:22:03]. - "The normal map and the displacement map should not be used together" (confirmed by Brecht): same relief, doubled bump, and a normal map on true displacement wrecks it; Node Wrangler and the Megascans importer get this wrong [00:29:57], [00:41:59]. Keep the normal map only if you never displace or combine heights [00:39:47]. - Texturing is mostly shader work: reuse the maps you have (displacement as a detail source for masks) instead of raising paint resolution [01:12:45], [01:18:48]. - "You have to label what you're building as you build it" [00:58:32]. ### PBR base - A texture is a per-pixel slider: a flat 0.2 gray Non-Color image into Roughness equals Roughness 0.2 [00:02:50]. - Real sizes: an 8 x 4 m wall, then 10 x 5 m so one 2.5 m texture tile maps exactly, scale applied [00:09:08], [00:19:58]. Keep a human scale reference in the scene [01:26:41]. - Download: 8K for a large wall, JPEG is fine for color and data, displacement 16-bit TIFF; skip ORM (for real-time) [00:15:32], [00:16:07]. - AO: Mix Color Multiply, A = base color, B = AO, factor 1 ("crank it all the way up almost always"): the flash-lit capture removed cavity shadows; optional under true displacement [00:22:36]-[00:26:23]. - All-black metallic map: delete it, it only costs render time [00:26:54]. - Normal Map node, not a direct link; yellow into purple is the missing-converter hint [00:27:25]. - True displacement: Displacement node into Material Output Displacement, Material Settings "Displacement and Bump", Subdivision Surface Simple (Catmull-Clark rounds the corners), shade smooth, Cubic interpolation on the height image, Poliigon Scale 0.2 (a fixed 20 cm range) and Midlevel 0.5 [00:32:05]-[00:39:15], [01:16:38]. EEVEE cannot do displacement only; it shows Displacement and Bump [00:32:50]. - Normal maps survive 8-bit; grayscale displacement needs 16-bit; bump derived from a grayscale map is worse than a normal map (slope from neighboring pixels) [00:37:36]. - Judge relief with a grazing sun in Cycles (sky strength 0.15, sun about 15 degrees): "our wall is paper thin" was the failure [00:35:56], [00:37:04]. - Node Wrangler Principled Setup needs fixing: normal plus displacement both wired, Linear, Scale 1, metallic added, no AO [00:41:59]. `M.pbr_from_textures` does not repeat these. ### Masks and layers - Color tweaks non-destructively: HSV after the AO multiply, hue shifts of about 0.04 only; frames show saturation 0.9, value 1.2 to 1.4 [00:47:10]-[00:50:22]. - Sun-fade mask: Noise (UV coordinates; Generated stretches) into a Color Ramp with stops pulled to about 0.47 and 0.71 so the mask reaches 0 and 1 [00:50:55]-[00:55:50]. Noise Detail cost roughly doubles per level: "I wouldn't go over like five or six" [00:57:26]. - The Mix Color node is the workhorse: A = previous color, B = effect color, Factor = mask [01:00:11], [01:23:29]. - Vertex colors for environment masks: image painting on tiling UVs repeats every stroke, needs a second UV map, wastes the image on long walls and must be redone when the layout changes; resolution is the vertex count, so subdivide (20 cuts) with high Subdivision modifiers lowered and hidden first (crash) [01:03:20]-[01:09:40]. Attribute node names are exact and case-sensitive [01:38:39]. - Color Burn: painted mask (A) burned by a contrast-ramped displacement map (B) breaks soft low-resolution paint along individual bricks; flip the ramp to move the effect into the grout; works at mid and low paint strength only [01:12:13]-[01:20:26]. Setting the effect Mix to Multiply turns B's value into an opacity control [01:19:22]. - Decal: Alpha into the Mix Factor, Extension Clip, Mapping for size and position; chain order sets age (under grime = older) [01:22:23]-[01:29:26]. - Roughness: never unplug the map to get the slider back; HSV Value below 1 shinier, above 1 duller; wet leaks = Mix toward a target roughness by the mold mask; the wall's roughness is about 0.7 [01:29:38]-[01:36:14]. Every property varies somewhere, "like in games" [01:34:35]. - Efflorescence: the same group with a flipped ramp in Screen (Add exceeds 1) [01:38:07]-[01:40:48]. Spalling: Subtract on the height before the Displacement node (factor about 0.14) and brighten the same area's color because fresh breaks have no grime [01:44:33]-[01:50:57]. - Masks follow reality: moisture in corners and around windows, big, medium and small shapes, compared against a photo reference throughout [01:02:53], [01:42:58]. - Paint while viewing the final shader, not the mask [01:21:32]; mute layers one at a time (`M`) to see each contribution [00:48:18]. ## Grant Abbitt (Gabbitt): texture painting 2021, 4.0, 4.3, 5.1 Videos WjS_zNQNVlw, E29D9rS8op8, _-zAhCOUpog, 7kSRXnAi7uA. Long-running Blender educator (GameDev.tv courses). - Setup: apply scale before unwrapping [E29D9rS8op8 00:00:33]; Smart UV Project island margin 0.01 to 0.05, larger when the texture will be downscaled for a game [E29D9rS8op8 00:01:40; WjS_zNQNVlw 00:01:27]; Texture Slots > Base Color 2048 x 2048, alpha off, filled with the dominant color [E29D9rS8op8 00:02:45]; bleed 12 px ("a really good idea") [E29D9rS8op8 00:04:21]; paint in Material Preview [00:03:18]. - The image is saved separately from the .blend; a star marks unsaved paint [WjS_zNQNVlw 00:02:50; 7kSRXnAi7uA 00:06:31]. - Texture paint projects the brush in screen space: strokes stretch on faces angled away and vanish at edges; paint facing the surface [_-zAhCOUpog 00:02:13]. No layers: Erase punches alpha (needs an alpha channel); the practical eraser is sampling the base color and painting it back [00:07:05], [00:07:38]. - 4.3 brush assets: Paint Hard (no pressure), Paint Hard Pressure (size), Paint Soft (strength), Paint Soft Pressure (both), Blur, Smear (blends more easily than Blur), Clone (source at the 3D cursor), Fill (strength 1 fills everything), Erase variants, Mask (needs a stencil image; give it a fake user) [_-zAhCOUpog 00:04:24]-[00:09:16]. Custom textured brush: duplicate the asset, white color, saturation 0, image texture, Anchored stroke, then "Save Changes to Asset" or the edits are lost [00:09:49]-[00:11:28]. - Stylized painted light (5.1): "the extremities you make lighter and the crevices you make darker"; start with a vertical gradient fill (dark bottom, light top); crevices with a darker color at strength about 0.25; edge highlights with a near-white brush in Screen; global darkening with a black Fill in Multiply at about 0.1 per tap ("Multiply is pretty much the same as darken, but it does it slightly better"); light only the crack edge facing the light; Smear to blend harsh lines [7kSRXnAi7uA 00:07:35]-[00:19:01]. Deciding condition against Price: stylized or unlit game art paints light into the albedo; PBR keeps albedo clean. - Judge hand-painted textures unlit: image straight into Material Output, or Solid with Flat lighting [7kSRXnAi7uA 00:12:27]. `M.preview(rigs=("unlit",))` and `M.painted_light` implement both. ## Ryan King (Ryan King Art): procedural nodes and texture painting Videos 5B244CYX1Tw, 4d4N8d4ki2Y, 6jT4K0JpGNs. Author of procedural material packs. - Object coordinates for procedurals ("for most procedural textures you're going to use object"), after applying scale; Generated stretches [5B244CYX1Tw 00:38:37], [00:40:12]. - Metallic 1 for metal, 0 otherwise; texture it only where the two mix [5B244CYX1Tw 00:18:15]. - Object Info Random into a Color Ramp gives each duplicate its own color from one material [5B244CYX1Tw 00:45:06]. Pointiness is a free cavity/edge mask, Cycles only, density dependent [00:41:19] (measured: EEVEE constant). - A mostly mid-gray factor blends weakly: add contrast before using it as a mask [5B244CYX1Tw 01:00:03] (measured: Noise Fac clusters in 0.3 to 0.7). - Never plug height into Normal: Bump node [5B244CYX1Tw 00:54:20]. - Painted bump: a 0.5-gray image through a Bump node; black carves, white raises; view in Material Preview [4d4N8d4ki2Y 00:44:43]-[00:47:24]. - Islands as large as possible, no overlaps, inside 0..1; fill a new image with the dominant color; 4K for a hero stylized prop [6jT4K0JpGNs 00:04:36]-[00:08:39]. Stroke spacing and jitter for dots, Line stroke for straight lines [6jT4K0JpGNs 00:18:59]. ## Surfaced Studio: texture painting starter (3.3) Video ywR8HsNeXQQ. - Texture Slots mode Material, not Single Image: paints every channel of every material [00:05:07]. - Most failures are state: wrong object holds the paint icon, missing UVs or image, a brush texture or texture mask, stroke settings; Load Factory Settings when lost [00:03:08], [00:28:21], [00:42:03]. - Face-selection paint masks stored as vertex groups give clean region borders [00:16:32]-[00:18:49]. - "Your texture mask defines where you paint, the texture defines what you're painting with" [00:33:25]. Roughness painted black (shinier) and white (rougher) through a Voronoi texture mask gives irregular patches [00:37:42]-[00:41:30]. ## NoPoly and YanSculpts - NoPoly (nQaUsgf9ZUg, self-described non-expert): UV coordinates for images, Object for procedurals [00:07:21]; the Mix factor only reads black and white, colors go in A/B [00:18:12]; a stretched 3D procedural (wood grain, Mapping scale (3, 3, 0.1), distortion 2, detail 5) only reads right on two axes [00:10:14], [00:11:20]. - YanSculpts (kbyK_N65YEk): course trailer, no technique; only a layer order for painted skin (base, variation, imperfections, freckles; separate gray maps for bump, specular and SSS) [00:02:24]. ## Disagreements and deciding conditions (whole skill) | Topic | Positions | Deciding condition | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | Procedural vs images | Price: scans plus masks; Kaspar, Ryan King: procedural | realistic hero surface vs stylized or variable asset | | Normal vs height | Price: one or the other; importers wire both | displacement or height combining planned: displacement; export or 8-bit: normal map | | Where masks live | Kaspar, Price: vertex colors; Abbitt, Ryan, Surfaced: images | mask frequency vs vertex density; tiling UVs and changing layouts favor vertex colors; export and fine detail favor images | | Light in albedo | Abbitt paints light (unlit art); Price keeps albedo clean, AO multiply only for scans; Kaspar allows a bit of AO in stylized diffuse | unlit or stylized game art vs PBR render | | AO | Price: multiply at 1 for scanned sets; game engines keep it separate (ORM) [added] | render with no true displacement vs real-time export | | Seam bleed | Kaspar 8 px, Abbitt 12 px | more when textures get downscaled | | Bake | Kaspar: at the end, for portability; Price: large environment detail stays in the shader (baking it would be "ginormous") | export target or EEVEE load time vs Cycles render | -
procedures.md 30.7 KB
# Tested procedures (Blender 5.2.1) Every `python` block below was executed on Blender 5.2.1 LTS by `tests/code/blender-texturing-shading/test_procedures_md.py` (headless, `--factory-startup`, fresh file per block). Blocks marked `python gui` need a live window and were run by `test_gui_texture_paint.py` instead. Larger behaviors were verified by the render tests named under each procedure; their contact sheets are in `tests/code/blender-texturing-shading/out/`. Blocks assume two names: `SKILL_SCRIPTS` = `<this skill>/scripts` and `TEX_DIR` = a folder holding a downloaded texture set. Replace them with real paths. --- ## P1. PBR material from a downloaded texture set Verified on 5.2.1. Test: `test_pbr.py` (ambientCG, Poly Haven and Substance naming, color spaces, glTF export). ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M maps = M.find_texture_set(TEX_DIR) # {'base_color': ..., 'roughness': ..., 'normal': ..., 'height': ..., 'ao': ...} bpy.ops.mesh.primitive_cube_add() obj = bpy.context.active_object mat = M.pbr_from_textures("Bricks", maps, uv_map="UVMap") # sRGB on color, Non-Color on data, sockets by name obj.data.materials.append(mat) print(M.audit_material(mat, obj)) # [] when color spaces, UV maps, bump and emission are sane ``` What the builder does, so you can do it by hand when needed: - Role from the END of the file name (`metal_plate_diff_1k` is base color, not metallic). `_nor_dx` / `NormalDX` sets the Normal Map node `convention = 'DIRECTX'` (5.1+). - `orm` / `arm` / `OcclusionRoughnessMetallic`: one Non-Color image, Separate Color R = AO, G = Roughness, B = Metallic. - Gloss maps are inverted into Roughness (`1 - gloss`). - Height: bump only when there is no normal map (`height_as='auto'`); Bump Distance = `height_depth` in meters (what 0..1 of the map spans). A normal map plus bump is CHAINED (Normal Map output into the Bump Normal input), never added. `height_as='displacement'` wires true displacement and DROPS the normal map (Price: they encode the same relief; `audit_material` flags materials that use both), sets the height image to Cubic interpolation and `displacement_method = 'BOTH'`. - AO: multiplied into base color at factor 1 by default (Price: scanned sets are flash-lit, AO restores the cavity shadow; optional under true displacement). For games use `ao_mode='gltf'`: a `glTF Material Output` group so the exporter writes a separate `occlusionTexture` (verified in the exported JSON); the default multiply still exported `baseColorTexture` (verified). `ao_mode='none'` to skip. - Emission map: Emission Strength set to 1 (5.x default is 0). Color-space fix for an existing material, by role of the socket it feeds: ```python import bpy bpy.ops.mesh.primitive_cube_add() mat = bpy.data.materials.new("Fixme") nt = mat.node_tree p = nt.nodes["Principled BSDF"] tex = nt.nodes.new("ShaderNodeTexImage") tex.image = bpy.data.images.new("rough_2k", 64, 64) nt.links.new(tex.outputs["Color"], p.inputs["Roughness"]) COLOR_SOCKETS = {"Base Color", "Emission Color", "Sheen Tint", "Coat Tint", "Specular Tint"} for n in nt.nodes: if n.bl_idname == "ShaderNodeTexImage" and n.image: targets = {l.to_socket.name for l in n.outputs["Color"].links} n.image.colorspace_settings.name = "sRGB" if targets & COLOR_SOCKETS else "Non-Color" print(tex.image.colorspace_settings.name) # Non-Color ``` True displacement with a scanned set (Price's wall), verified in `test_price_masks.py` (normal map dropped, Cubic, BOTH) and the block test: ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_plane_add(size=2.5) # one tile = the set's published physical size wall = bpy.context.active_object mat = M.pbr_from_textures("Wall", M.find_texture_set(TEX_DIR), uv_map="UVMap", height_as="displacement", height_depth=0.2) # Poliigon: 0.2 = fixed 20 cm range wall.data.materials.append(mat) sub = wall.modifiers.new("Subdiv", "SUBSURF") sub.subdivision_type = "SIMPLE" # Catmull-Clark rounds the corners (Price) sub.levels = sub.render_levels = 6 bpy.ops.object.shade_smooth() # faceted lumps otherwise print(mat.displacement_method, mat.get("bx_note"), M.audit_material(mat, wall)) ``` ## P2. Principled BSDF v2 by name, and the traps Verified on 5.2.1 (socket list and defaults probed). ```python import bpy mat = bpy.data.materials.new("V2") # 5.x: already has Principled + Output, use_nodes is deprecated p = mat.node_tree.nodes["Principled BSDF"] print([s.name for s in p.inputs][:12]) p.inputs["Base Color"].default_value = (0.8, 0.3, 0.2, 1) p.inputs["Roughness"].default_value = 0.55 p.inputs["Specular IOR Level"].default_value = 0.5 # old 'Specular' p.inputs["Subsurface Weight"].default_value = 1.0 # old 'Subsurface'; no Subsurface Color any more p.inputs["Subsurface Scale"].default_value = 0.02 # meters; radius = Scale x Subsurface Radius p.inputs["Coat Weight"].default_value = 0.0 # old 'Clearcoat' p.inputs["Emission Color"].default_value = (1, 0.5, 0.1, 1) p.inputs["Emission Strength"].default_value = 2.0 # default 0: color alone does nothing p.subsurface_method = "RANDOM_WALK_SKIN" mat.surface_render_method = "DITHERED" # EEVEE; old blend_method ``` ## P3. Box (triplanar) projection for meshes without usable UVs Verified on 5.2.1, EEVEE and Cycles render the same. Test: `test_pbr.py` (ico sphere with no UV map). Use for rigid props, rocks, AI-generated meshes before UVs exist. Not for deforming meshes: Object coordinates swim under an armature (UV digest). A tangent-space normal map is interpreted in the UV tangent frame, which box projection does not provide, so the builder drops the normal map and uses the height as bump [added]. ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M maps = {k: v for k, v in M.find_texture_set(TEX_DIR).items() if k not in ("normal", "normal_dx")} bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=4) rock = bpy.context.active_object mat = M.pbr_from_textures("Rock_Box", maps, projection="BOX", box_blend=0.25, scale=(1.5, 1.5, 1.5), height_depth=0.01) rock.data.materials.append(mat) print([n.projection for n in mat.node_tree.nodes if n.bl_idname == "ShaderNodeTexImage"]) ``` ## P4. Wear, dirt and color variation on any material Verified on 5.2.1. Test: `test_wear.py` (mask sheet Cycles vs EEVEE, preview, value report) and `tests/code/blender-texturing-shading/probes/probe_stochastic.py`. ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_cube_add() crate = bpy.context.active_object mat = M.get_material("Crate_Paint") p = M.principled(mat) p.inputs["Base Color"].default_value = (0.045, 0.11, 0.065, 1) # green paint, linear p.inputs["Roughness"].default_value = 0.5 crate.data.materials.append(mat) nodes = M.add_wear(mat, edge_color=(0.62, 0.62, 0.63), edge_roughness=0.3, edge_metallic=1.0, dirt_color=(0.04, 0.035, 0.03), dirt_amount=0.85, grunge_amount=0.3, radius=0.04, wear_amount=0.6, cavity_dirt=0.5, noise_scale=10.0, grunge_scale=3.0) print(sorted(nodes), [s.name for s in nodes["edge_wear"].outputs]) ``` Measured behavior: - `BX Edge Wear`: Edges = angle between the Bevel-node normal and the shading normal, kept where AO says the surface is open (convex); Wear = Edges plus stretched noise through a threshold (chips); Cavity = 1 - AO; Dirt = Cavity plus noise through a threshold. Radius is world units: 0.04 read well on a 1 m crate, 0.015 gave hairlines. - EEVEE: Edges and Wear go to 0 (the Bevel node is Cycles-only), Cavity/Dirt differ (screen-space AO). Grunge and Variation match exactly (mean 0.0442 vs 0.044). - Bevel and AO are sampled per render sample: a threshold on them averages to fractional values. Metallic driven through `GREATER_THAN` was 0% fractional at 1 spp and 17 to 18% at 16 and 128 spp. Bake Wear and Dirt to textures (scenario-blender-uv-baking, Emit bake) before thresholding for finals, EEVEE and games. - Noise Texture `Fac` clusters around 0.5 (roughly 0.3 to 0.7): stretch it with Map Range before using it as a breakup, or the breakup is invisible. ## P5. Engine-independent curvature and dirt masks (no Bevel node) Verified on 5.2.1. Tests: `test_wear.py` (EEVEE and Cycles identical, mean 0.3402 vs 0.3404), `test_uvpaint.py` (rasterized into a texture). ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_monkey_add() obj = bpy.context.active_object obj.modifiers.new("s", "SUBSURF").levels = 2 bpy.ops.object.modifier_apply(modifier="s") val = M.curvature_attribute(obj, name="col_curvature", smooth=1, contrast=6.0) # 0.5 flat, >0.5 convex mat = M.get_material("CurvatureView") vc = mat.node_tree.nodes.new("ShaderNodeVertexColor") vc.layer_name = "col_curvature" mat.node_tree.links.new(vc.outputs["Color"], M.principled(mat).inputs["Base Color"]) obj.data.materials.append(mat) # Blender's own cavity mask: works headless in OBJECT mode on the ACTIVE color attribute a = obj.data.color_attributes.new("col_dirt", "BYTE_COLOR", "POINT") obj.data.color_attributes.active_color = a print(bpy.ops.paint.vertex_color_dirt(blur_strength=1.0, blur_iterations=1, clean_angle=3.14, dirt_angle=0.0, dirt_only=False, normalize=True)) ``` Both depend on vertex density (like Pointiness). On a low poly, compute them on the high and bake, or rasterize per texel with `M.vertex_values_to_texels` (P11). ## P6. Decal through a dedicated UV map (Kaspar's logo method) Verified on 5.2.1. Test: `test_uvpaint.py` (logo on a sphere, render). ```python import sys, bpy, numpy as np sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, segments=64, ring_count=32) ball = bpy.context.active_object logo = bpy.data.images.new("logo", 128, 128, alpha=True) a = np.zeros((128, 128, 4), dtype=np.float32) a[40:88, 40:88] = (1, 1, 1, 1) # white square with alpha, stands in for a logo file M.write_pixels(logo, a) mat = M.get_material("BallPaint") ball.data.materials.append(mat) M.add_decal(mat, ball, logo, center=(0, -0.5, 0), normal=(0, -1, 0), up=(0, 0, 1), size=0.45) print([u.name for u in ball.data.uv_layers]) # ['UVMap', 'decal'] ``` Faces facing the decal get planar UVs; every other loop is parked outside 0..1 and the image node uses Extension CLIP, so it reads alpha 0 there. For games, a separate decal mesh floating a millimeter above the surface is the usual alternative [added]. ## P7. Stylized skin (Kaspar, Snow #1, #2, #6, translated to Principled v2) Verified on 5.2.1. Test: `test_skin.py` (EEVEE and Cycles, studio, backlit and roughness-only rigs; two SSS ranges side by side). ```python import sys, bpy, numpy as np sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_monkey_add(size=0.24) # human-scale head, ears at +-X head = bpy.context.active_object co = np.array([v.co for v in head.data.vertices]) ear = np.clip((np.abs(co[:, 0]) - 0.10) / 0.03, 0, 1) M.write_vertex_values(head, "col_sss", np.maximum(0.25, ear)) # black/white mask, values set in nodes M.smooth_attribute(head, "col_sss", iterations=3) # the headless blur brush M.mask_from_points(head, "col_brighter", [((0, -0.12, -0.02), 0.05, 0.8)], smooth=2) skin = M.stylized_skin("snow.skin", obj=head, coords="OBJECT", pore_scale=40.0) head.data.materials.append(skin) p = M.principled(skin) print(p.subsurface_method, sorted(s.name for s in p.inputs if s.is_linked)) ``` Recipe inside the builder (sources in expert-notes.md): - Base color: start darker, brighten through `col_brighter`, darken through `col_darker`, lips mask removed from both, lips color on top. - SSS: Subsurface Weight 1; `col_sss` stays black/white and a Map Range sets Subsurface Scale. Kaspar's v1 values were 0.02 to 0.15. In 5.2 (Random Walk rescaled) 0.02 to 0.15 made the ears of a 0.33 m head glow white-pink and waxy under a backlight; 0.005 to 0.04 gave the red glow he asks for (default, measured). Scale proportionally with the character. - Roughness: base 0.68 to 0.72; pores from two Voronoi Smooth F1 textures (scale 100 and 200 on his UVs) mixed by a detail-0 noise, general noise mixed at about 0.2, pore centers glossier, cracks rougher; `col_rough` remapped to 0.72 to 1 (chin, brows); lips glossy (0.45 here; his tongue and teeth 0.4). - Specular IOR Level stays 0.5: wetness is roughness. Backlit SSS check: `M.preview([head, ...], out, rigs=("backlit",))` puts a strong area light straight behind plus one from below (Kaspar's rig). Ears, nostrils, lids, lips and fingers should glow red; forehead, jaw and palm center stay opaque. ## P8. Procedural eye and cornea Verified on 5.2.1. Tests: `test_skin.py` (eyes on the head, cornea shadow per engine), `tests/code/blender-texturing-shading/probes/probe_cornea.py` (EEVEE thickness variants). ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M r = 0.012 bpy.ops.mesh.primitive_uv_sphere_add(segments=48, ring_count=24, radius=r) # origin = eyeball center eye = bpy.context.active_object bpy.ops.object.shade_smooth() eye.data.materials.append(M.stylized_eye("eye.L", radius=r, forward="-Y", iris_color=(0.10, 0.20, 0.33))) bpy.ops.mesh.primitive_uv_sphere_add(segments=48, ring_count=24, radius=r * 1.04) cornea = bpy.context.active_object bpy.ops.object.shade_smooth() cornea.data.materials.append(M.cornea_material("cornea")) cornea.visible_shadow = False # Kaspar: the cornea casts no shadow bpy.context.scene.eevee.use_raytracing = True out = M.output_node(bpy.data.materials["cornea"]) print(out.inputs["Thickness"].is_linked, bpy.data.materials["cornea"].thickness_mode) # True SLAB ``` Measured: with default thickness EEVEE refracts the shell as a solid glass ball and blows the iris across the eye; Material Output Thickness 0 (SLAB or SPHERE) matches Cycles; `use_raytrace_refraction = False` makes the eye vanish behind a probe reflection. `visible_shadow = False` is honored by both engines (iris region brightness 0.59 to 0.76 in Cycles, 0.53 to 0.67 in EEVEE). ## P9. Anisotropic hair or brushed metal (Cycles only) Verified on 5.2.1. Test: `test_skin.py` (aniso sheet): EEVEE renders the same material isotropic in 5.2, Cycles shows the stretched highlight. Kaspar kept a fake-anisotropy node group for EEVEE and a separate Cycles output (Material Output `target`). ```python import bpy bpy.ops.mesh.primitive_uv_sphere_add() mat = bpy.data.materials.new("HairShine") nt = mat.node_tree p = nt.nodes["Principled BSDF"] p.inputs["Roughness"].default_value = 0.35 p.inputs["Anisotropic"].default_value = 1.0 t = nt.nodes.new("ShaderNodeTangent") t.direction_type = "UV_MAP" t.uv_map = "UVMap" # a UV map laid along the strand direction ('uv alignment') nt.links.new(t.outputs["Tangent"], p.inputs["Tangent"]) streak = nt.nodes.new("ShaderNodeTexNoise") # stands in for his painted streak texture streak.inputs["Scale"].default_value = 8.0 rng = nt.nodes.new("ShaderNodeMapRange") rng.inputs["To Max"].default_value = 0.5 # his rotation range 0..0.5 nt.links.new(streak.outputs["Fac"], rng.inputs["Value"]) nt.links.new(rng.outputs["Result"], p.inputs["Anisotropic Rotation"]) out = nt.nodes["Material Output"] out.target = "CYCLES" # an EEVEE output with a fake setup can sit beside it bpy.context.active_object.data.materials.append(mat) ``` ## P10. Texture painting with real brushes (live GUI session) GUI only, verified in `test_gui_texture_paint.py` (opens a window). Facts from that run: - Factory 5.2: brush `Paint Hard`, color AND unified color black, `use_unified_color` True, seam bleed 2 px, mode `MATERIAL`. - A stroke issued in the same call that set up the object, view or mode painted 162 texels in one run and full coverage (31,395) in another; resampled strokes after a redraw painted about 14,000 every run. Projection painting occludes with the depth buffer of the last viewport draw, so what that draw contained decides the result. `M.gui_redraw()` forces a draw first. - Scripted strokes paint one dab per point (the brush's spacing is not applied): 16 points over 740 px left separate dots. `M.gui_texture_stroke()` resamples to 0.25 x brush diameter. - Occlude, Backface Culling and Normal falloff off: one stroke painted 9,969 texels on the front AND 9,969 on the back (Kaspar's through-the-clump hair streaks). With defaults: front only. - `Blur` is image_brush_type `SOFTEN`; `Fill` floods the whole canvas (1,048,540 of 1,048,576 texels). - `paint.add_texture_paint_slot` (via `bx_gui.run`) creates the image node, links Base Color, image sRGB. - Vertex-paint strokes through bx_gui were unreliable in the first tests (only strokes near the view center registered). Cause found and fixed in bx_gui on 2026-09-24: the exec path reads stroke `location` in object space and needs the point on the surface; `bx_gui.stroke()` now raycasts each point from the view and converts to object space. Re-verified in a GUI session: a 14-point `VERTEX_PAINT` stroke across an off-origin sphere painted its full width (55 vertices, local x -0.25 to +0.25). The numpy route (P7) stays the headless option and the one to use for exact, procedural masks. - From the batch notes (Abbitt, Surfaced Studio, Ryan King): strokes project from the screen, so they stretch on faces angled away and vanish at edges: `set_view` square to the surface first. Erase brushes punch alpha and need an image with alpha; on opaque maps paint the base color back. Texture Slots mode `MATERIAL` paints every channel of every material; `add_texture_paint_slot(type='ROUGHNESS')` sets Non-Color itself, `type='BUMP'` wires a Bump node (batch probes). Face-selection paint mask: `mesh.use_paint_mask = True`; stencil mask image: `image_paint.use_stencil_layer`, `image_paint.stencil_image`. A custom textured brush needs white color, saturation 0, and "Save Changes to Asset". "Cannot paint" checklist: wrong object holds the paint icon, missing UVs, no image slot, an active stencil or mask image, a black brush texture, extreme stroke spacing. - Bleed: Kaspar 8 px, Abbitt 12 px (default 2). Island margin 0.01 to 0.05, larger when the texture will be downscaled for a game. ```python gui import sys, bpy sys.path.append(EXPERT_SCRIPTS); sys.path.append(SKILL_SCRIPTS) import bx_gui as G, bx_materials as M ob = bpy.context.active_object # UV-mapped mesh with a material img = M.new_image("tex_body_color", 2048, color=(0.5, 0.5, 0.5, 1), colorspace="sRGB") nt = ob.active_material.node_tree tex = nt.nodes.new("ShaderNodeTexImage"); tex.image = img nt.links.new(tex.outputs["Color"], nt.nodes["Principled BSDF"].inputs["Base Color"]) G.set_view("FRONT", frame=ob) bpy.ops.object.mode_set(mode="TEXTURE_PAINT") ip = bpy.context.tool_settings.image_paint ip.mode, ip.canvas, ip.seam_bleed = "IMAGE", img, 8 # Kaspar: bleed 8 px M.gui_texture_stroke([(-0.3, -1.0, 0.4), (0.3, -1.0, 0.4)], size=40, brush="Paint Hard", color=(0.8, 0.1, 0.1)) M.save_image(img, "/abs/path/tex_body_color.png") # images are not saved with the .blend by default ``` ## P11. Headless painting through UV space Verified on 5.2.1. Test: `test_uvpaint.py`: strokes crossing UV seams stay continuous on a cube corner and across a sphere's seam (painted_sheet.png); 512 px TexelMaps for a cube and a 64x32 sphere built in 0.36 s. ```python import sys, bpy, numpy as np, tempfile, os sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_uv_sphere_add(radius=0.5, segments=64, ring_count=32) ball = bpy.context.active_object tm = M.TexelMap(ball, 512) # world position/normal per texel img = M.new_image("ball_color", 512, color=(0.45, 0.45, 0.45, 1), colorspace="sRGB") arr = M.pixels(img) ring = [(0.5 * np.sin(a) * 0.95, 0.5 * np.cos(a) * 0.95, 0.15) for a in np.linspace(0, 2 * np.pi, 40)] M.paint_stroke(arr, tm, ring, radius=0.06, color=(0.9, 0.75, 0.1, 1), hardness=0.6) # crosses the seam M.write_pixels(img, M.dilate(arr, tm.valid, 8)) dust = M.project(tm, lambda P, N: np.clip((N[..., 2] - 0.4) / 0.4, 0, 1)) # up-facing = dust mask = M.new_image("ball_dust", 512, colorspace="Non-Color") d4 = np.ones((512, 512, 4), dtype=np.float32); d4[..., :3] = M.dilate(dust, tm.valid, 8)[..., None] M.write_pixels(mask, d4) out = tempfile.mkdtemp() M.save_image(img, os.path.join(out, "ball_color.png")) M.save_image(mask, os.path.join(out, "ball_dust.png")) print(round(tm.coverage, 3)) ``` Image value semantics (measured by writing 0.5 and reading the PNG): byte images store values as encoded (0.5 becomes 128 whether sRGB or Non-Color); float images hold linear and a `Linear Rec.709` float saved to PNG is converted to sRGB (0.5 becomes 0.735); a float Non-Color image saved to 16-bit PNG keeps 0.5. Write alpha 1 into maps without real alpha: an RGB PNG save multiplies color by alpha (0.5 with alpha 0.5 saved as 64). ## P12. Photo texture pattern extraction (Kaspar, Snow #4) Verified on 5.2.1. Test: `test_uvpaint.py`: low-frequency std 0.129 to 0.018, pattern correlation 0.99. ```python import sys, numpy as np sys.path.append(SKILL_SCRIPTS) import bx_materials as M n = 256 y, x = np.mgrid[0:n, 0:n] / n weave = 0.5 + 0.15 * np.sign(np.sin(2 * np.pi * x * 32) * np.sin(2 * np.pi * y * 32)) photo = np.clip(weave + 0.3 * np.sin(2 * np.pi * x) * np.cos(2 * np.pi * y), 0, 1) # weave + baked light detail = M.high_pass(photo, sigma=16) # blur + grain extract: original - blur + 0.5 print(round(float(M.gaussian_blur(detail, 16).std()), 3)) ``` Use the result as a Non-Color mask and bump; build color, grunge and wear procedurally on top. ## P13. Checks: audit, value report, previews Verified on 5.2.1. Tests: `test_pbr.py` (a deliberately wrong material raises 8 findings; the good one none), `test_wear.py`, `test_skin.py`. ```python import sys, bpy, tempfile sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_uv_sphere_add() obj = bpy.context.active_object mat = M.get_material("Check") M.principled(mat).inputs["Metallic"].default_value = 0.5 obj.data.materials.append(mat) print(M.audit_material(mat, obj)) # flags the gray metallic rep = M.value_report([obj], res=96, views=("front",)) # renders channels as emission to linear EXR print(rep["problems"]) sheet = M.preview(mat, tempfile.mkdtemp(), engines=("BLENDER_EEVEE",), rigs=("studio",), res=160, samples=8) print(sheet) ``` `preview` rigs: `studio` (key, fill, rim on neutral 0.18 gray, no gradient), `backlit` (Kaspar's SSS test), `roughness` (studio with Base Color forced black and SSS off: reads roughness and bump alone). Engines side by side catch EEVEE-only or Cycles-only nodes. ## P14. Camera-ray world for lookdev (Kaspar, Snow #7) Verified on 5.2.1 (built and rendered in `test_procedures_md.py`). ```python import bpy w = bpy.context.scene.world nt = w.node_tree env = nt.nodes.new("ShaderNodeTexEnvironment") # Environment Texture, not Image Texture, for an HDRI lit = nt.nodes["Background"] flat = nt.nodes.new("ShaderNodeBackground") flat.inputs["Color"].default_value = (0.18, 0.18, 0.18, 1) lp = nt.nodes.new("ShaderNodeLightPath") mix = nt.nodes.new("ShaderNodeMixShader") nt.links.new(env.outputs["Color"], lit.inputs["Color"]) nt.links.new(lp.outputs["Is Camera Ray"], mix.inputs["Fac"]) nt.links.new(lit.outputs["Background"], mix.inputs[1]) nt.links.new(flat.outputs["Background"], mix.inputs[2]) nt.links.new(mix.outputs["Shader"], nt.nodes["World Output"].inputs["Surface"]) ``` The camera sees flat gray, the object is lit by the HDRI. ## P15. Global clay switch driven by one property (Kaspar, Snow finale) Verified on 5.2.1. Keeps bump and normals (unlike `view_layer.material_override`). ```python import bpy ctrl = bpy.data.objects.new("lookdev_ctrl", None) bpy.context.scene.collection.objects.link(ctrl) ctrl["clay"] = 0.0 mat = bpy.data.materials.new("AnyMat") nt = mat.node_tree p = nt.nodes["Principled BSDF"] mix = nt.nodes.new("ShaderNodeMix"); mix.data_type = "RGBA" mix.inputs[7].default_value = (0.6, 0.6, 0.6, 1) # B = clay gray (RGBA mode: A=inputs[6], B=inputs[7]) nt.links.new(mix.outputs[2], p.inputs["Base Color"]) d = mix.inputs[0].driver_add("default_value").driver v = d.variables.new() v.targets[0].id = ctrl v.targets[0].data_path = '["clay"]' d.expression = v.name print(d.is_valid) ``` Add the same driven mix on Roughness (to 0.6) and Subsurface Weight (to 0) in every material; `ctrl["clay"] = 1` turns the whole character to clay for the presentation turntable. ## P17. Hand-painted stylized light, procedurally (Grant Abbitt, 5.1 guide) Verified on 5.2.1. Test: `test_painted_light.py` (unlit and studio rigs, EEVEE and Cycles; albedo top third 0.216 vs bottom third 0.177 linear). Grant paints the light into the albedo for unlit game shaders: lighter top, darker bottom, MULTIPLY in crevices, SCREEN on extremities, judged with no lighting. The node version builds the same structure from masks; bake it (Emit) for the game, then add hand details (cracks, lettering) on top in the GUI or with P11. ```python import sys, bpy sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_cube_add(size=1.0) stone = bpy.context.active_object stone.modifiers.new("s", "SUBSURF").levels = 3 bpy.ops.object.modifier_apply(modifier="s") M.curvature_attribute(stone, smooth=2, contrast=6.0) # engine-independent crevice/extremity masks mat = M.get_material("stone_painted") M.principled(mat).inputs["Base Color"].default_value = (0.18, 0.19, 0.2, 1) stone.data.materials.append(mat) M.painted_light(mat, obj=stone, top=1.15, bottom=0.8, crevice=0.45, highlight=0.4) print([n.label for n in mat.node_tree.nodes if n.label]) ``` Without a curvature attribute the builder falls back to the Bevel/AO group (Cycles only, bake before use in EEVEE or a game). Preview with `M.preview(objs, out, rigs=("unlit", "studio"))`: `unlit` routes Base Color through emission, Grant's "plug the image straight into the output" check. GUI brush equivalents he uses (5.1): Fill brush with gradient (`brush.color_type = 'GRADIENT'`, drag bottom to top in front view), Fill in MULTIPLY with black at strength about 0.1 per tap to darken globally, a brush in SCREEN with near-white for edge highlights, MULTIPLY at strength about 0.25 for crevices and cracks, Smear to soften a harsh line. ## P18. Environment grime from a numpy color-attribute mask plus Color Burn (Price) Verified on 5.2.1. Test: `test_price_masks.py` (plain soft mask vs burned mask on a brick wall, EEVEE and Cycles; the burned mold concentrates in the grout). Price paints the mask in vertex colors (resolution = vertex count, he subdivides a wall with 20 cuts) because image painting on tiling UVs repeats every stroke. Headless, the mask comes from a rule instead of strokes. ```python import sys, bpy, numpy as np sys.path.append(SKILL_SCRIPTS) import bx_materials as M bpy.ops.mesh.primitive_plane_add(size=2.0) wall = bpy.context.active_object bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.subdivide(number_cuts=20) bpy.ops.object.mode_set(mode="OBJECT") co = np.array([v.co for v in wall.data.vertices]) M.write_vertex_values(wall, "black", np.clip(1 - (co[:, 1] + 1.0) / 0.9, 0, 1)) # mold rising from the bottom M.smooth_attribute(wall, "black", 3) mat = M.get_material("wall_grime") wall.data.materials.append(mat) nt = mat.node_tree p = M.principled(mat) attr = nt.nodes.new("ShaderNodeAttribute"); attr.attribute_name = "black" # exact, case-sensitive name height = nt.nodes.new("ShaderNodeTexNoise") # stands in for the set's displacement map ramp = nt.nodes.new("ShaderNodeValToRGB") # contrast: scanned height sits in mid grays ramp.color_ramp.elements[0].position, ramp.color_ramp.elements[1].position = 0.35, 0.65 ramp.color_ramp.elements[0].color = (1, 1, 1, 1) # flipped: low (grout) keeps the mask ramp.color_ramp.elements[1].color = (0.55, 0.55, 0.55, 1) nt.links.new(height.outputs["Fac"], ramp.inputs["Fac"]) burn = nt.nodes.new("ShaderNodeMix"); burn.data_type = "RGBA"; burn.blend_type = "BURN" burn.inputs[0].default_value = 1.0 nt.links.new(attr.outputs["Fac"], burn.inputs[6]) nt.links.new(ramp.outputs["Color"], burn.inputs[7]) mold = nt.nodes.new("ShaderNodeMix"); mold.data_type = "RGBA" mold.inputs[6].default_value = (0.3, 0.1, 0.07, 1) # A: the existing base-color chain in a real material mold.inputs[7].default_value = (0.008, 0.008, 0.007, 1) nt.links.new(burn.outputs[2], mold.inputs[0]) nt.links.new(mold.outputs[2], p.inputs["Base Color"]) print(burn.blend_type, attr.attribute_name in wall.data.color_attributes) ``` Price's layering rules around it: keep painted values mid-range (burn detail shows only at mid and low mask values); set the effect Mix to MULTIPLY and B's value becomes the opacity; efflorescence is the same group with a flipped ramp in SCREEN; spalling is SUBTRACT on the height before the Displacement node (factor about 0.14) plus brighter color in the same area; wetness is a Mix on roughness toward about 0.35 by the same mask; label each group as you build it. ## P19. Physical UV scale for tiling sets (Price) Verified on 5.2.1 (block test). One UV unit must equal the texture's published tile size in meters on an object with applied scale. ```python import bpy, bmesh bpy.ops.mesh.primitive_plane_add(size=1.0) wall = bpy.context.active_object wall.scale = (10.0, 5.0, 1.0) bpy.ops.object.transform_apply(scale=True) # all experts: apply scale first bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.unwrap(method="ANGLE_BASED", margin=0.0) # always pass method (5.2 default is CONFORMAL) bm = bmesh.from_edit_mesh(wall.data) uv = bm.loops.layers.uv.active tile = 2.5 # meters per texture tile (vendor data) us = [l[uv].uv.x for f in bm.faces for l in f.loops] span_u = max(us) - min(us) # UV width of the 10 m side k = (10.0 / tile) / span_u for f in bm.faces: for l in f.loops: l[uv].uv *= k bmesh.update_edit_mesh(wall.data) bpy.ops.object.mode_set(mode="OBJECT") xs = [d.vector.x for d in wall.data.uv_layers.active.uv] print(round(max(xs) - min(xs), 3)) # 4.0 tiles across the 10 m side ``` ## P20. Baking procedural channels (pointer to scenario-blender-uv-baking) Belongs to scenario-blender-uv-baking. The texturing-side rules: bake at the end for portability (other software, EEVEE load time, game export), not for Cycles speed (Kaspar: Sprite Fright is mostly procedural and unbaked); route each channel through an Emission shader and bake EMIT; 30 samples minimum for anything with high frequencies, never 1 for normals; disable Mask modifier render visibility first; validate by rebuilding the material from the maps and rendering both side by side (`M.preview` on both, or `M.value_report`). In 5.x the target image node must be selected AND active or the bake silently returns CANCELED (UV digest). -
sources.md 11.2 KB
# Sources Per-video notes live in the project's `notes/texturing/`; digests `_digest_snow_shading.md` and `_digest_texturing_pbr_painting.md`. The batch digest states that none of its experts gives albedo ranges, calls roughness the most important map, shows box projection or bakes procedurals; those parts of this skill are [added] or measured. URLs are `https://www.youtube.com/watch?v=<id>`. ## Julien Kaspar, Blender Studio: "Snow" stylized character shading lives (2021) Credential: character artist at Blender Studio (Spring, Sprite Fright), author of the Stylized Character Workflow course and the free Rain rig. Blender 3.0 alpha on screen (Principled v1, EEVEE Legacy): values need the 5.2 translations in `expert-notes.md`. Auto captions, no frames. | # | Id / URL | Length | Best for | Best timestamps | | --- | ---------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | 1 | [0vGNOtX9iPk](https://www.youtube.com/watch?v=0vGNOtX9iPk) | 2:20 | base colors, black/white vertex-color masks, judging color honestly | 00:09:32 Diffuse Color + Standard; 01:03:33 mask layers; 01:19:25 vertex colors vs texture paint; 02:06:31 face vs vertex masking | | 2 | [kN5aq1qXUCE](https://www.youtube.com/watch?v=kN5aq1qXUCE) | 2:22 | masks gating procedurals, lip streaks on a dedicated UV, fabric, bump Distance fix | 01:03:05 textures via masks; 01:23:24 bump Distance; 01:45:30 UV band masks; 01:56:20 weave direction on hems | | 3 | [uTjfRGGV6ys](https://www.youtube.com/watch?v=uTjfRGGV6ys) | 2:02 | seam creases from strip UVs, painted 32-bit height, normalizing summed normals | 00:48:52 crossing masks; 00:55:37 painted height setup; 01:25:29 normalize for Cycles | | 4 | [rKVeLjxZzTc](https://www.youtube.com/watch?v=rKVeLjxZzTc) | 2:22 | photo-texture pattern extraction, stitches, color-variation map as mask | 00:02:47 blur + grain extract; 01:24:35 stitch group; 02:04:12 wash masks | | 5 | [kAdgrBS4eDA](https://www.youtube.com/watch?v=kAdgrBS4eDA) | 2:02 | procedural eye, sclera, cornea, Mask modifier | 01:10:25 spherical gradient; 01:22:52 sclera tint; 01:41:28 cornea shaders; 01:48:26 pupil scale before bake | | 6 | [D7kuKx7BV1A](https://www.youtube.com/watch?v=D7kuKx7BV1A) | 2:15 | skin SSS by anatomy, backlit test, pores in roughness, roughness zones, hair anisotropy | 00:12:43 SSS placement; 00:16:06 backlight rig; 00:24:31 SSS color network; 01:00:38 pores in roughness; 01:26:22 roughness zones | | 7 | [8GD1C9qLi-4](https://www.youtube.com/watch?v=8GD1C9qLi-4) | 2:28 | lookdev turntable, camera-ray world, hair streak painting through clumps | 00:22:52 turntable logic; 00:35:14 camera-ray world; 00:47:42 through-strokes; 01:09:34 Cycles anisotropy | | F | [EDEMbeZfVcc](https://www.youtube.com/watch?v=EDEMbeZfVcc) | 2:08 | node-tree cleanup, clay/wire switch, bake recipe, presentation lighting | 00:06:04 cleanup; 00:15:26 clay switch; 00:31:58 bake setup; 01:36:11 samples for normals | ## Texturing course and texture-painting batch (`_digest_texturing_pbr_painting.md`) | Expert / channel | Id / URL | Length, Blender | Best for | Best timestamps | | ------------------------------------------------------------------------- | ---------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Andrew Price, Blender Guru (founder of Poliigon, 9 years of scan capture) | [uHCJoNEWjXo](https://www.youtube.com/watch?v=uHCJoNEWjXo) | 1:53, 4.2 | realistic PBR wiring, normal OR displacement, displacement settings, masks, vertex-color grime, Color Burn, decals, roughness edits | 00:21:29 Non-Color; 00:24:44 AO multiply; 00:29:57 normal vs displacement; 00:32:05 true displacement; 00:57:26 Noise Detail cost; 01:03:20 vertex colors vs image paint; 01:12:13 Color Burn; 01:24:02 decals; 01:30:11 roughness edits; 01:44:33 spalling | | Grant Abbitt (GameDev.tv courses) | [7kSRXnAi7uA](https://www.youtube.com/watch?v=7kSRXnAi7uA) | 0:21, 5.1 | stylized hand-painted light with blend modes, unlit judging | 00:07:35 gradient fill; 00:10:18 Screen highlights; 00:12:27 unlit preview; 00:14:05 Multiply fill | | Grant Abbitt | [_-zAhCOUpog](https://www.youtube.com/watch?v=_-zAhCOUpog) | 0:12, 4.3 | brush assets, screen projection limit, Mask stencil, custom brush asset | 00:02:13 screen projection; 00:07:38 erasing; 00:08:10 mask; 00:10:56 save asset | | Grant Abbitt | [E29D9rS8op8](https://www.youtube.com/watch?v=E29D9rS8op8) | 0:05, 4.0 | setup: apply scale, margin 0.01, bleed 12 | 00:00:33; 00:04:21 | | Grant Abbitt | [WjS_zNQNVlw](https://www.youtube.com/watch?v=WjS_zNQNVlw) | 0:03, 2.93 | island margin reasoning, save the image | 00:01:27; 00:02:50 | | Ryan King Art (procedural material packs) | [5B244CYX1Tw](https://www.youtube.com/watch?v=5B244CYX1Tw) | 1:09, 2.93 | Object coordinates, metallic binary, Object Info Random, Pointiness, contrast before masking | 00:18:15; 00:40:12; 00:45:06; 01:00:03 | | Ryan King Art | [4d4N8d4ki2Y](https://www.youtube.com/watch?v=4d4N8d4ki2Y) | 1:04, 2.9x | painted bump (0.5 gray), stencil photo brush | 00:44:43; 00:56:33 | | Ryan King Art | [6jT4K0JpGNs](https://www.youtube.com/watch?v=6jT4K0JpGNs) | 0:27, 3.3 | paint setup checklist, stroke spacing and jitter | 00:04:36; 00:08:08; 00:18:59 | | Surfaced Studio (tutorial and course channel) | [ywR8HsNeXQQ](https://www.youtube.com/watch?v=ywR8HsNeXQQ) | 0:44, 3.3 | Material slot mode, face-selection paint masks, texture vs mask, "cannot paint" checklist | 00:05:07; 00:17:09; 00:33:25; 00:42:03 | | NoPoly (self-described non-expert) | [nQaUsgf9ZUg](https://www.youtube.com/watch?v=nQaUsgf9ZUg) | 0:25, 4.4 | UV vs Object coordinates, stretched-procedural axis limit | 00:07:21; 00:11:20 | | YanSculpts | [kbyK_N65YEk](https://www.youtube.com/watch?v=kbyK_N65YEk) | 0:07, 2.7x | course trailer only; painted-skin layer order | 00:02:24 | ## Non-video references - Blender 5.2 release notes (cached in `archive/tests/version-deltas/rn/5.2/`): Random Walk SSS rescaling (rendering.md, cycles.md), Principled Thin Wall. - `sources/blender-version-deltas.md` sections 4, 8, 9, 10, 12 (texture paint, baking, materials, EEVEE, color management). - Albedo ranges (dielectric about sRGB 30 to 240, metal reflectance 180 to 255): common PBR guidance (Adobe Substance PBR guide), cited as [added], not re-verified against the guide in this build and not stated by any source video.
-
-
scripts
-
bx_materials.py 85.2 KB
""" bx_materials: material builders, headless UV-space painting and render-based material checks for Blender 5.2.1 (skill: scenario-blender-texturing-shading). Every function below is exercised by tests/code/blender-texturing-shading/test_*.py. import sys; sys.path.append("<skills>/scenario-blender-texturing-shading/scripts"); import bx_materials as M Builders (get-or-create by name, Principled BSDF v2 sockets addressed by NAME): maps = M.find_texture_set("/abs/textures/bricks") # role -> path, from file names mat = M.pbr_from_textures("Bricks", maps, uv_map="UVMap") # colour spaces set per role mat = M.pbr_from_textures("Rock", maps, projection="BOX") # box/triplanar, no UVs needed M.add_wear(mat, edge_color=(0.75, 0.72, 0.68), edge_roughness=0.3, dirt_color=(0.05, 0.04, 0.03)) ng = M.edge_wear_group(); ng = M.grunge_group() # reusable node groups M.stylized_skin("snow.skin", obj=body) # Kaspar's Snow recipe in v2 terms M.stylized_eye("snow.eye", iris_color=(0.18, 0.3, 0.45)) # object-space procedural eye M.add_decal(mat, obj, "/abs/logo.png", center, normal, up, size) Vertex masks (Color Attributes, engine independent, no GUI): M.curvature_attribute(obj) # col_curvature: 0.5 flat, >0.5 convex, <0.5 concave M.mask_from_points(obj, "col_sss", [(co, radius, value), ...]) M.smooth_attribute(obj, "col_sss", iterations=4) Headless painting through UV space (the substitute for texture-paint strokes): tm = M.TexelMap(obj, 1024) # world position + normal of every texel img = M.new_image("rust_mask", 1024, color=(0, 0, 0, 1), colorspace="Non-Color") arr = M.pixels(img) M.paint_stroke(arr, tm, world_points, radius=0.05, color=(1, 1, 1, 1)) # seam-proof dabs arr[...] = M.project(tm, lambda P, N: N[..., 2]) # e.g. dust from normal z M.write_pixels(img, M.dilate(arr, tm.valid, 8)); M.save_image(img, "/abs/rust_mask.png") M.high_pass(gray, sigma) # Kaspar's grain-extract pattern extraction Live GUI session (real brushes through scenario-blender-expert/scripts/bx_gui.py): M.gui_texture_stroke(world_points, size=40, brush="Paint Hard", color=(0.8, 0.1, 0.1)) # forces a redraw first, resamples dabs Checks: M.audit_material(mat) # static: colour spaces by role, UV maps, bump, emission... M.value_report(objs) # renders albedo/roughness/metallic as emission to EXR, stats M.preview(mat_or_objs, out_dir) # EEVEE + Cycles contact sheet, studio and backlit rigs Conventions: colours passed to node sockets are scene-linear. Byte images store values as encoded (sRGB images: sRGB-encoded; Non-Color: raw); float images store linear. Always write alpha = 1 into images without meaningful alpha: an RGB PNG save multiplies colour by alpha. """ __version__ = "0.1" # Blender Expert Skills v0.1 (2026-09-24) import math import os import re import sys import bpy import numpy as np from mathutils import Vector _HERE = os.path.dirname(os.path.abspath(__file__)) _EXPERT = os.path.normpath(os.path.join(_HERE, "..", "..", "scenario-blender-expert", "scripts")) if _EXPERT not in sys.path: sys.path.append(_EXPERT) # -------------------------------------------------------------------------------------- # node helpers # -------------------------------------------------------------------------------------- def get_material(mat): """Material by object or name (created if missing; 5.x new materials already hold Principled BSDF + Material Output, `use_nodes` is deprecated).""" if isinstance(mat, bpy.types.Material): return mat m = bpy.data.materials.get(mat) return m if m is not None else bpy.data.materials.new(mat) def output_node(mat): nt = mat.node_tree outs = [n for n in nt.nodes if n.bl_idname == "ShaderNodeOutputMaterial"] for n in outs: if n.is_active_output: return n return outs[0] if outs else nt.nodes.new("ShaderNodeOutputMaterial") def principled(mat): """The Principled BSDF that feeds the active output (or the first one).""" nt = mat.node_tree out = output_node(mat) if out.inputs["Surface"].is_linked: n = out.inputs["Surface"].links[0].from_node if n.bl_idname == "ShaderNodeBsdfPrincipled": return n for n in nt.nodes: if n.bl_idname == "ShaderNodeBsdfPrincipled": return n return None def _new(nt, idname, loc=(0, 0), label=None, **props): n = nt.nodes.new(idname) n.location = loc if label: n.label = label for k, v in props.items(): setattr(n, k, v) return n def _sock(sockets, name): """First ENABLED socket called `name` (Mix and Map Range carry hidden duplicates).""" for s in sockets: if s.name == name and s.enabled: return s raise KeyError(name) def _link(nt, a, b): return nt.links.new(a, b) def _set(sock, value): if hasattr(sock, "default_value"): dv = sock.default_value if hasattr(dv, "__len__") and not hasattr(value, "__len__"): value = [value] * len(dv) elif hasattr(dv, "__len__") and len(value) != len(dv): value = tuple(value) + (1.0,) * (len(dv) - len(value)) sock.default_value = value def _feed(nt, sock, src): """src: a socket (linked), or a constant.""" if src is None: return if isinstance(src, bpy.types.NodeSocket): _link(nt, src, sock) else: _set(sock, src) def _mix(nt, dtype="RGBA", blend="MIX", fac=None, a=None, b=None, loc=(0, 0), label=None, clamp=False): n = _new(nt, "ShaderNodeMix", loc, label) n.data_type = dtype if dtype == "RGBA": n.blend_type = blend n.clamp_result = clamp _feed(nt, _sock(n.inputs, "Factor"), fac) _feed(nt, _sock(n.inputs, "A"), a) _feed(nt, _sock(n.inputs, "B"), b) return n, _sock(n.outputs, "Result") def _math(nt, op, a=None, b=None, loc=(0, 0), clamp=False, label=None): n = _new(nt, "ShaderNodeMath", loc, label, operation=op, use_clamp=clamp) _feed(nt, n.inputs[0], a) if b is not None: _feed(nt, n.inputs[1], b) return n, n.outputs[0] def _maprange(nt, v, fmin, fmax, tmin=0.0, tmax=1.0, interp="LINEAR", loc=(0, 0), clamp=True, label=None): n = _new(nt, "ShaderNodeMapRange", loc, label, interpolation_type=interp, clamp=clamp) _feed(nt, _sock(n.inputs, "Value"), v) _feed(nt, _sock(n.inputs, "From Min"), fmin) _feed(nt, _sock(n.inputs, "From Max"), fmax) _feed(nt, _sock(n.inputs, "To Min"), tmin) _feed(nt, _sock(n.inputs, "To Max"), tmax) return n, _sock(n.outputs, "Result") def _upstream(sock): """(from_socket or None, default value) of an input socket.""" if sock.is_linked: return sock.links[0].from_socket, None dv = sock.default_value return None, (tuple(dv) if hasattr(dv, "__len__") else dv) def _source(nt, sock, loc): """A socket carrying what currently feeds `sock` (an RGB/Value node for constants).""" s, dv = _upstream(sock) if s is not None: return s if sock.type == "RGBA": n = _new(nt, "ShaderNodeRGB", loc) n.outputs[0].default_value = dv return n.outputs[0] n = _new(nt, "ShaderNodeValue", loc) n.outputs[0].default_value = dv return n.outputs[0] def _group_node(nt, ng, loc=(0, 0)): g = _new(nt, "ShaderNodeGroup", loc) g.node_tree = ng return g def _iface(ng, name, io, stype, default=None, lo=None, hi=None): s = ng.interface.new_socket(name=name, in_out=io, socket_type=stype) if default is not None: s.default_value = default if lo is not None: s.min_value = lo if hi is not None: s.max_value = hi return s def srgb_to_linear(c): c = np.asarray(c, dtype=np.float64) return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def linear_to_srgb(c): c = np.clip(np.asarray(c, dtype=np.float64), 0, None) return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(c, 1 / 2.4) - 0.055) # -------------------------------------------------------------------------------------- # texture sets # -------------------------------------------------------------------------------------- # roles whose images are colour (sRGB); every other role is data (Non-Color) COLOR_ROLES = {"base_color", "emission"} IMAGE_EXT = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".exr", ".tga", ".bmp", ".webp"} _SKIP = {"preview", "thumb", "thumbnail", "sphere", "render", "cube", "swatch"} _ROLE_TOKENS = [ ("orm", {"orm", "arm", "occlusionroughnessmetallic", "occlusionroughnessmetalness"}), ("normal", {"normal", "nor", "nrm", "norm", "normalgl", "normaldx", "normalmap"}), ("ao", {"ao", "ambientocclusion", "occlusion", "occ"}), ("roughness", {"roughness", "rough", "rgh"}), ("gloss", {"gloss", "glossiness"}), ("metallic", {"metallic", "metalness", "metal", "mtl"}), ("height", {"height", "disp", "displacement", "bump", "heightmap"}), ("opacity", {"opacity", "alpha", "transparency"}), ("emission", {"emission", "emissive", "emit", "glow"}), ("specular", {"specular", "spec", "reflection", "refl"}), ("base_color", {"basecolor", "albedo", "diffuse", "diff", "color", "colour", "col", "base"}), ] def _tokens(stem): s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stem) # camelCase -> camel_Case s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s) return [t for t in re.split(r"[^a-zA-Z0-9]+", s.lower()) if t] _NOISE_TOK = re.compile(r"^(\d+k|\d+|png|jpe?g|exr|tiff?|tga|webp|gl|dx|opengl|directx|\d+bits?|bit|lod\d*|udim|srgb|linear|map|tex|texture|t|m)$") def classify_texture(filename): """Role of a texture file from its name, or None. The role word is taken from the END of the name (skipping resolution/format tokens) so a material called 'metal_plate' does not turn its diffuse into a metallic map. Normal maps return 'normal' or 'normal_dx' (DirectX green: ambientCG _NormalDX, Poly Haven _nor_dx).""" stem, ext = os.path.splitext(os.path.basename(filename)) if ext.lower() not in IMAGE_EXT: return None toks = _tokens(stem) if any(t in _SKIP for t in toks): return None flat = "".join(toks) if {"orm", "arm"} & set(toks) or "occlusionroughnessmetal" in flat: return "orm" dx = bool({"dx", "directx"} & set(toks)) or "normaldx" in flat or "nordx" in flat role_of = {} for role, words in _ROLE_TOKENS: for w in words: role_of.setdefault(w, role) for i in range(len(toks) - 1, -1, -1): t = toks[i] if _NOISE_TOK.match(t): continue pair = toks[i - 1] + t if i > 0 else None role = role_of.get(pair) if pair else None role = role or role_of.get(t) if role is None: continue if role == "normal" and dx: return "normal_dx" return role return None def find_texture_set(folder, stem=None): """{role: path} for the images in `folder` (optionally only names containing `stem`). Preference when a role appears twice: exr > png > tif > jpg, then larger file.""" pref = {".exr": 0, ".png": 1, ".tif": 2, ".tiff": 2, ".tga": 3, ".jpg": 4, ".jpeg": 4} found = {} for f in sorted(os.listdir(folder)): if stem and stem.lower() not in f.lower(): continue role = classify_texture(f) if role is None: continue p = os.path.join(folder, f) key = (pref.get(os.path.splitext(f)[1].lower(), 5), -os.path.getsize(p)) if role not in found or key < found[role][0]: found[role] = (key, p) return {r: v[1] for r, v in found.items()} def load_image(path, role): """Load (or reuse) an image and set its colour space for the role.""" img = bpy.data.images.load(path, check_existing=True) img.colorspace_settings.name = "sRGB" if role in COLOR_ROLES else "Non-Color" return img def pbr_from_textures(mat, maps, uv_map=None, projection="UV", box_blend=0.2, scale=(1.0, 1.0, 1.0), height_as="auto", height_depth=0.005, displacement_midlevel=0.5, ao_mode="multiply", ao_strength=1.0, clear=True): """Wire a texture set into a Principled BSDF v2 by socket NAME. maps: {role: path or bpy Image}; roles base_color, roughness, gloss, metallic, normal, normal_dx, height, ao, orm (R=AO, G=roughness, B=metallic; Poly Haven 'arm'), opacity, emission. Unknown roles are ignored ('specular' is reported, not wired). uv_map: explicit UV map name for every image node (Kaspar: never rely on the active render UV). projection='BOX' uses Object coordinates + box blending (no UVs). height_as: 'auto' (bump only when there is no normal map), 'bump', 'displacement' (true displacement; the normal map is then NOT wired: Price, confirmed by Brecht, normal map and displacement encode the same relief), 'none'. height_depth: metres that height 0..1 spans; becomes the Bump Distance (the 5.2 default 0.001 is 1 mm) or the Displacement Scale (Poliigon: 0.2, a fixed 20 cm range). ao_mode: 'multiply' (Price: AO x base colour at factor 1 for scanned sets, restores the cavity shadow the flash capture removed; optional under true displacement), 'gltf' (game export: separate occlusion via a glTF Material Output group, never baked into albedo), 'none'. Returns the material. Non-Color is set on every data map, sRGB on colour maps.""" mat = get_material(mat) nt = mat.node_tree if clear: for n in list(nt.nodes): nt.nodes.remove(n) out = _new(nt, "ShaderNodeOutputMaterial", (900, 0)) p = _new(nt, "ShaderNodeBsdfPrincipled", (500, 0)) _link(nt, p.outputs["BSDF"], out.inputs["Surface"]) maps = dict(maps) if "normal_dx" in maps and "normal" not in maps: maps["normal"], dx = maps.pop("normal_dx"), True else: dx = False maps.pop("normal_dx", None) if projection == "BOX": tc = _new(nt, "ShaderNodeTexCoord", (-1100, 0)) vec_src = tc.outputs["Object"] else: uvn = _new(nt, "ShaderNodeUVMap", (-1100, 0)) if uv_map: uvn.uv_map = uv_map vec_src = uvn.outputs["UV"] mapping = _new(nt, "ShaderNodeMapping", (-900, 0)) mapping.inputs["Scale"].default_value = scale _link(nt, vec_src, mapping.inputs["Vector"]) y = [400] def tex(role): src = maps[role] img = src if isinstance(src, bpy.types.Image) else load_image(src, role) img.colorspace_settings.name = "sRGB" if role in COLOR_ROLES else "Non-Color" n = _new(nt, "ShaderNodeTexImage", (-600, y[0]), role) y[0] -= 300 n.image = img if projection == "BOX": n.projection, n.projection_blend = "BOX", box_blend _link(nt, mapping.outputs["Vector"], n.inputs["Vector"]) return n ao_sock = None if "base_color" in maps: t = tex("base_color") base = t.outputs["Color"] else: base = None if "orm" in maps: t = tex("orm") sep = _new(nt, "ShaderNodeSeparateColor", (-300, y[0] + 300)) _link(nt, t.outputs["Color"], sep.inputs["Color"]) _link(nt, sep.outputs["Green"], p.inputs["Roughness"]) _link(nt, sep.outputs["Blue"], p.inputs["Metallic"]) ao_sock = sep.outputs["Red"] if "roughness" in maps: _link(nt, tex("roughness").outputs["Color"], p.inputs["Roughness"]) elif "gloss" in maps: _, inv = _math(nt, "SUBTRACT", 1.0, tex("gloss").outputs["Color"], (-300, y[0] + 300)) _link(nt, inv, p.inputs["Roughness"]) if "metallic" in maps: _link(nt, tex("metallic").outputs["Color"], p.inputs["Metallic"]) if "ao" in maps and ao_sock is None: ao_sock = tex("ao").outputs["Color"] if base is not None: if ao_sock is not None and ao_mode == "multiply": _, base = _mix(nt, "RGBA", "MULTIPLY", ao_strength, base, ao_sock, (-150, 350), "AO multiply") _link(nt, base, p.inputs["Base Color"]) if "opacity" in maps: _link(nt, tex("opacity").outputs["Color"], p.inputs["Alpha"]) mat.surface_render_method = "DITHERED" if "emission" in maps: _link(nt, tex("emission").outputs["Color"], p.inputs["Emission Color"]) p.inputs["Emission Strength"].default_value = 1.0 # 5.x default is 0 normal_out = None if "normal" in maps and projection == "BOX": maps.pop("normal") # tangent frame comes from UVs, box projection has none [added] mat["bx_note"] = "normal map dropped: box projection (height drives bump instead)" if "normal" in maps and height_as == "displacement" and "height" in maps: maps.pop("normal") # one or the other, never both (Price) mat["bx_note"] = "normal map dropped: true displacement in use" if "normal" in maps: t = tex("normal") nm = _new(nt, "ShaderNodeNormalMap", (-150, y[0] + 300)) if uv_map: nm.uv_map = uv_map if dx: nm.convention = "DIRECTX" _link(nt, t.outputs["Color"], nm.inputs["Color"]) normal_out = nm.outputs["Normal"] mode = height_as if mode == "auto": mode = "bump" if ("height" in maps and "normal" not in maps) else "none" if "height" in maps and mode in ("bump", "displacement"): t = tex("height") t.interpolation = "Cubic" # Price: hides 8/16-bit stepping if mode == "bump": b = _new(nt, "ShaderNodeBump", (150, -400)) b.inputs["Distance"].default_value = height_depth b.inputs["Strength"].default_value = 1.0 _link(nt, t.outputs["Color"], b.inputs["Height"]) if normal_out is not None: # chain, never add normals _link(nt, normal_out, b.inputs["Normal"]) normal_out = b.outputs["Normal"] else: d = _new(nt, "ShaderNodeDisplacement", (500, -500)) d.inputs["Midlevel"].default_value = displacement_midlevel d.inputs["Scale"].default_value = height_depth _link(nt, t.outputs["Color"], d.inputs["Height"]) _link(nt, d.outputs["Displacement"], out.inputs["Displacement"]) mat.displacement_method = "BOTH" if normal_out is not None: _link(nt, normal_out, p.inputs["Normal"]) if ao_mode == "gltf" and ao_sock is not None: ng = bpy.data.node_groups.get("glTF Material Output") if ng is None: ng = bpy.data.node_groups.new("glTF Material Output", "ShaderNodeTree") _iface(ng, "Occlusion", "INPUT", "NodeSocketFloat", 1.0, 0.0, 1.0) g = _group_node(nt, ng, (900, -300)) _link(nt, ao_sock, g.inputs["Occlusion"]) mat["bx_texture_set"] = {k: (v.filepath if isinstance(v, bpy.types.Image) else v) for k, v in maps.items()} return mat # -------------------------------------------------------------------------------------- # wear, dirt, variation # -------------------------------------------------------------------------------------- def edge_wear_group(name="BX Edge Wear"): """Node group: Edges (convex), Cavity (concave), Wear (edges broken by noise into chips), Dirt (cavities broken by noise). Edges = angle between the Bevel-node normal and the shading normal (linear across the bevel radius), kept only where AO says the surface is open (convex). Cavity = 1 - AO. Cycles only for Edges/Wear: the Bevel node returns the plain normal in EEVEE (masks go black, verified), and EEVEE's AO node is screen-space (Cavity/Dirt differ). For EEVEE or a game engine bake these outputs (scenario-blender-uv-baking) or use curvature_attribute().""" ng = bpy.data.node_groups.get(name) if ng is not None: return ng ng = bpy.data.node_groups.new(name, "ShaderNodeTree") _iface(ng, "Radius", "INPUT", "NodeSocketFloat", 0.03, 0.0, 10.0) _iface(ng, "Edge Sharpness", "INPUT", "NodeSocketFloat", 0.35, 0.01, 1.57) _iface(ng, "Cavity Distance", "INPUT", "NodeSocketFloat", 0.1, 0.0, 10.0) _iface(ng, "Noise Scale", "INPUT", "NodeSocketFloat", 12.0, 0.0, 1000.0) _iface(ng, "Breakup", "INPUT", "NodeSocketFloat", 0.8, 0.0, 2.0) _iface(ng, "Wear Amount", "INPUT", "NodeSocketFloat", 0.5, 0.0, 1.0) _iface(ng, "Dirt Amount", "INPUT", "NodeSocketFloat", 0.5, 0.0, 1.0) for o in ("Wear", "Edges", "Cavity", "Dirt"): _iface(ng, o, "OUTPUT", "NodeSocketFloat") gi = _new(ng, "NodeGroupInput", (-1400, 0)) go = _new(ng, "NodeGroupOutput", (900, 0)) geo = _new(ng, "ShaderNodeNewGeometry", (-1200, 300)) bev = _new(ng, "ShaderNodeBevel", (-1200, 100)) bev.samples = 16 _link(ng, gi.outputs["Radius"], bev.inputs["Radius"]) dot = _new(ng, "ShaderNodeVectorMath", (-1000, 200), operation="DOT_PRODUCT") _link(ng, geo.outputs["Normal"], dot.inputs[0]) _link(ng, bev.outputs["Normal"], dot.inputs[1]) _, dotc = _math(ng, "MINIMUM", dot.outputs["Value"], 1.0, (-850, 200)) _, ang = _math(ng, "ARCCOSINE", dotc, None, (-700, 200)) # 0 flat, 0.785 on a 90 deg edge _, edge = _maprange(ng, ang, 0.0, gi.outputs["Edge Sharpness"], loc=(-500, 200)) ao = _new(ng, "ShaderNodeAmbientOcclusion", (-1200, -200), only_local=True, samples=16) _link(ng, gi.outputs["Cavity Distance"], ao.inputs["Distance"]) _, openness = _maprange(ng, ao.outputs["AO"], 0.75, 0.95, loc=(-700, 0)) _, edge = _math(ng, "MULTIPLY", edge, openness, (-300, 200)) _, cav = _math(ng, "SUBTRACT", 1.0, ao.outputs["AO"], (-1000, -200)) _, cav = _maprange(ng, cav, 0.0, 0.5, loc=(-700, -200)) tc = _new(ng, "ShaderNodeTexCoord", (-1200, -500)) nz = _new(ng, "ShaderNodeTexNoise", (-1000, -500)) nz.inputs["Detail"].default_value = 5.0 # Price: Detail above 5-6 blows up shader cost nz.inputs["Roughness"].default_value = 0.65 _link(ng, tc.outputs["Object"], nz.inputs["Vector"]) _link(ng, gi.outputs["Noise Scale"], nz.inputs["Scale"]) # Noise Fac clusters around 0.5 (roughly 0.3..0.7): stretch it to -1..1 first _, n0 = _maprange(ng, nz.outputs["Fac"], 0.3, 0.7, -1.0, 1.0, loc=(-800, -500)) _, nbw = _math(ng, "MULTIPLY", gi.outputs["Breakup"], 0.5, (-800, -700)) _, nb = _math(ng, "MULTIPLY", n0, nbw, (-600, -500)) # thresholds: a higher amount lowers the level the mask must pass _, wthr = _math(ng, "SUBTRACT", 1.0, gi.outputs["Wear Amount"], (-500, -300)) _, wthr_hi = _math(ng, "ADD", wthr, 0.06, (-300, -300)) _, e2 = _math(ng, "ADD", edge, nb, (-100, 200)) _, wear = _maprange(ng, e2, wthr, wthr_hi, loc=(250, 200)) _, dthr = _math(ng, "SUBTRACT", 1.0, gi.outputs["Dirt Amount"], (-500, -700)) _, dthr_hi = _math(ng, "ADD", dthr, 0.2, (-300, -700)) _, d2 = _math(ng, "ADD", cav, nb, (-100, -200)) _, dirt = _maprange(ng, d2, dthr, dthr_hi, loc=(250, -200)) _link(ng, wear, go.inputs["Wear"]) _link(ng, edge, go.inputs["Edges"]) _link(ng, cav, go.inputs["Cavity"]) _link(ng, dirt, go.inputs["Dirt"]) return ng def grunge_group(name="BX Grunge"): """Node group: Variation (low-frequency 0..1 around 0.5, for hue/value shifts), Grunge (spotty coverage mask), Streaks (vertical, stretched along Z). Works in EEVEE and Cycles. Feed Vector with Object coords (rigid props) or UV (deforming meshes).""" ng = bpy.data.node_groups.get(name) if ng is not None: return ng ng = bpy.data.node_groups.new(name, "ShaderNodeTree") _iface(ng, "Vector", "INPUT", "NodeSocketVector") _iface(ng, "Scale", "INPUT", "NodeSocketFloat", 4.0, 0.0, 1000.0) _iface(ng, "Variation Scale", "INPUT", "NodeSocketFloat", 1.5, 0.0, 1000.0) _iface(ng, "Coverage", "INPUT", "NodeSocketFloat", 0.3, 0.0, 1.0) _iface(ng, "Softness", "INPUT", "NodeSocketFloat", 0.1, 0.001, 1.0) _iface(ng, "Streak Stretch", "INPUT", "NodeSocketFloat", 10.0, 1.0, 100.0) for o in ("Variation", "Grunge", "Streaks"): _iface(ng, o, "OUTPUT", "NodeSocketFloat") gi = _new(ng, "NodeGroupInput", (-1200, 0)) go = _new(ng, "NodeGroupOutput", (800, 0)) nv = _new(ng, "ShaderNodeTexNoise", (-800, 300)) nv.inputs["Detail"].default_value = 2.0 _link(ng, gi.outputs["Vector"], nv.inputs["Vector"]) _link(ng, gi.outputs["Variation Scale"], nv.inputs["Scale"]) _link(ng, nv.outputs["Fac"], go.inputs["Variation"]) ng_ = _new(ng, "ShaderNodeTexNoise", (-800, 0)) ng_.inputs["Detail"].default_value = 5.0 ng_.inputs["Roughness"].default_value = 0.7 _link(ng, gi.outputs["Vector"], ng_.inputs["Vector"]) _link(ng, gi.outputs["Scale"], ng_.inputs["Scale"]) vo = _new(ng, "ShaderNodeTexVoronoi", (-800, -250)) _link(ng, gi.outputs["Vector"], vo.inputs["Vector"]) _, vs = _math(ng, "MULTIPLY", gi.outputs["Scale"], 2.0, (-1000, -250)) _link(ng, vs, vo.inputs["Scale"]) _, g = _mix(ng, "FLOAT", fac=0.3, a=ng_.outputs["Fac"], b=vo.outputs["Distance"], loc=(-550, 0)) # coverage: fraction of the surface that ends up above the threshold (roughly) _, thr = _math(ng, "SUBTRACT", 0.7, _math(ng, "MULTIPLY", gi.outputs["Coverage"], 0.4, loc=(-550, -200))[1], (-350, -200)) _, thr_hi = _math(ng, "ADD", thr, gi.outputs["Softness"], (-150, -200)) _, grunge = _maprange(ng, g, thr, thr_hi, loc=(100, 0)) _link(ng, grunge, go.inputs["Grunge"]) mp = _new(ng, "ShaderNodeMapping", (-800, -550)) _link(ng, gi.outputs["Vector"], mp.inputs["Vector"]) _, inv = _math(ng, "DIVIDE", 1.0, gi.outputs["Streak Stretch"], (-1000, -600)) cz = _new(ng, "ShaderNodeCombineXYZ", (-1000, -750)) cz.inputs[0].default_value = 1.0 cz.inputs[1].default_value = 1.0 _link(ng, inv, cz.inputs[2]) _link(ng, cz.outputs[0], mp.inputs["Scale"]) ns = _new(ng, "ShaderNodeTexNoise", (-550, -550)) ns.inputs["Detail"].default_value = 5.0 _link(ng, mp.outputs["Vector"], ns.inputs["Vector"]) _, ss = _math(ng, "MULTIPLY", gi.outputs["Scale"], 1.5, (-800, -800)) _link(ng, ss, ns.inputs["Scale"]) _, streaks = _maprange(ng, ns.outputs["Fac"], 0.5, 0.75, loc=(100, -550)) _link(ng, streaks, go.inputs["Streaks"]) return ng def add_wear(mat, edge_color=None, edge_roughness=None, edge_metallic=None, dirt_color=(0.06, 0.05, 0.04), dirt_roughness=0.9, dirt_amount=0.8, grunge_amount=0.35, variation=0.06, coords="OBJECT", uv_map=None, radius=0.03, wear_amount=0.5, cavity_dirt=0.5, noise_scale=12.0, grunge_scale=4.0): """Insert colour variation, grunge/dirt and edge wear between whatever feeds the Principled Base Color / Roughness (/ Metallic) and the BSDF. Returns the group nodes. edge_*: what worn convex edges become (None = leave that channel). Dirt goes into cavities (AO) and grunge spots; it darkens and roughens. variation: +- value shift. Edge wear and cavity dirt use Bevel/AO, i.e. Cycles; grunge/variation work everywhere. Bevel/AO are sampled per render sample: any threshold on them (wear chips, the 0/1 metallic here) averages to fractional values at the border (17-18% grey metallic at 16-128 spp, 0% at 1 spp, measured). For final frames, EEVEE or games bake Wear/Dirt to textures first (scenario-blender-uv-baking), then threshold the baked mask.""" mat = get_material(mat) nt = mat.node_tree p = principled(mat) x0, y0 = p.location.x - 700, p.location.y bc = _source(nt, p.inputs["Base Color"], (x0 - 400, y0 + 300)) rg = _source(nt, p.inputs["Roughness"], (x0 - 400, y0 - 100)) if coords == "UV": c = _new(nt, "ShaderNodeUVMap", (x0 - 600, y0 - 600)) if uv_map: c.uv_map = uv_map vec = c.outputs["UV"] else: vec = _new(nt, "ShaderNodeTexCoord", (x0 - 600, y0 - 600)).outputs["Object"] gg = _group_node(nt, grunge_group(), (x0 - 350, y0 - 600)) gg.label = "grunge" _link(nt, vec, gg.inputs["Vector"]) gg.inputs["Scale"].default_value = grunge_scale gg.inputs["Coverage"].default_value = grunge_amount ew = _group_node(nt, edge_wear_group(), (x0 - 350, y0 - 950)) ew.label = "edge wear" ew.inputs["Radius"].default_value = radius ew.inputs["Wear Amount"].default_value = wear_amount ew.inputs["Dirt Amount"].default_value = cavity_dirt ew.inputs["Noise Scale"].default_value = noise_scale # variation: value (and a touch of hue) from the low-frequency noise hsv = _new(nt, "ShaderNodeHueSaturation", (x0, y0 + 300), "variation") _, hue = _maprange(nt, gg.outputs["Variation"], 0.0, 1.0, 0.5 - variation * 0.15, 0.5 + variation * 0.15, loc=(x0 - 200, y0 + 450)) _, val = _maprange(nt, gg.outputs["Variation"], 0.0, 1.0, 1.0 - variation * 2, 1.0 + variation * 2, loc=(x0 - 200, y0 + 150)) _link(nt, hue, hsv.inputs["Hue"]) _link(nt, val, hsv.inputs["Value"]) _link(nt, bc, hsv.inputs["Color"]) # dirt = max(cavity dirt, grunge) * amount _, dmask = _math(nt, "MAXIMUM", ew.outputs["Dirt"], gg.outputs["Grunge"], (x0 + 50, y0 - 300)) _, dmask = _math(nt, "MULTIPLY", dmask, dirt_amount, (x0 + 200, y0 - 300), clamp=True) _, col = _mix(nt, "RGBA", "MIX", dmask, hsv.outputs["Color"], dirt_color, (x0 + 250, y0 + 300), "dirt") _, rough = _mix(nt, "FLOAT", fac=dmask, a=rg, b=dirt_roughness, loc=(x0 + 250, y0 - 100)) if edge_color is not None: _, col = _mix(nt, "RGBA", "MIX", ew.outputs["Wear"], col, edge_color, (x0 + 450, y0 + 300), "edge wear") if edge_roughness is not None: _, rough = _mix(nt, "FLOAT", fac=ew.outputs["Wear"], a=rough, b=edge_roughness, loc=(x0 + 450, y0 - 100)) if edge_metallic is not None: # metallic stays a 0/1 mask: harden the soft wear edge (value_report flagged 19% # grey metallic when the soft mask drove it directly) mt = _source(nt, p.inputs["Metallic"], (x0 - 400, y0 - 250)) _, hard = _math(nt, "GREATER_THAN", ew.outputs["Wear"], 0.5, (x0 + 250, y0 - 250)) _, met = _mix(nt, "FLOAT", fac=hard, a=mt, b=edge_metallic, loc=(x0 + 450, y0 - 250)) _link(nt, met, p.inputs["Metallic"]) _link(nt, col, p.inputs["Base Color"]) _link(nt, rough, p.inputs["Roughness"]) return {"grunge": gg, "edge_wear": ew} def painted_light(mat, obj=None, curvature="col_curvature", top=1.15, bottom=0.8, crevice=0.45, highlight=0.4, highlight_color=(1.0, 0.95, 0.85)): """Grant Abbitt's hand-painted stylized pass (5.1 guide) as nodes, for baking into an unlit/game albedo: vertical gradient (lighter top, darker bottom), MULTIPLY darkening in crevices, SCREEN highlights on extremities. Masks come from a curvature colour attribute when `obj` has one (engine independent; see curvature_attribute), else from the BX Edge Wear group (Bevel/AO, Cycles). Judge it with preview(rigs=('unlit',)) and bake it (Emit) for the game. Returns the final colour socket.""" mat = get_material(mat) nt = mat.node_tree p = principled(mat) x0, y0 = p.location.x - 900, p.location.y + 500 base = _source(nt, p.inputs["Base Color"], (x0 - 300, y0)) tc = _new(nt, "ShaderNodeTexCoord", (x0 - 700, y0 + 300)) sep = _new(nt, "ShaderNodeSeparateXYZ", (x0 - 500, y0 + 300)) _link(nt, tc.outputs["Generated"], sep.inputs[0]) # 0 at the bottom of the bbox, 1 at the top _, grad = _maprange(nt, sep.outputs["Z"], 0.0, 1.0, bottom, top, loc=(x0 - 300, y0 + 300), label="vertical gradient") _, col = _mix(nt, "RGBA", "MULTIPLY", 1.0, base, _math(nt, "MULTIPLY", grad, 1.0, (x0 - 150, y0 + 300))[1], (x0, y0 + 200), "gradient") if obj is not None and obj.type == "MESH" and curvature in obj.data.color_attributes: vc = _new(nt, "ShaderNodeVertexColor", (x0 - 500, y0 - 200), curvature) vc.layer_name = curvature k = vc.outputs["Color"] _, cav = _maprange(nt, k, 0.45, 0.2, loc=(x0 - 300, y0 - 150), label="crevices") _, edge = _maprange(nt, k, 0.55, 0.8, loc=(x0 - 300, y0 - 350), label="extremities") else: ew = _group_node(nt, edge_wear_group(), (x0 - 500, y0 - 250)) cav, edge = ew.outputs["Cavity"], ew.outputs["Edges"] _, fc = _math(nt, "MULTIPLY", cav, crevice, (x0 - 100, y0 - 150)) _, col = _mix(nt, "RGBA", "MULTIPLY", fc, col, (0.0, 0.0, 0.0), (x0 + 200, y0), "crevices (multiply)") _, fe = _math(nt, "MULTIPLY", edge, highlight, (x0 - 100, y0 - 350)) _, col = _mix(nt, "RGBA", "SCREEN", fe, col, highlight_color, (x0 + 400, y0), "highlights (screen)") _link(nt, col, p.inputs["Base Color"]) return col # -------------------------------------------------------------------------------------- # stylized character shading (Julien Kaspar, Snow lives, translated to Principled v2) # -------------------------------------------------------------------------------------- def _attr_fac(nt, obj, name, loc): """Fac of a colour attribute if the mesh has it, else None.""" if obj is None or obj.type != "MESH" or name not in obj.data.color_attributes: return None n = _new(nt, "ShaderNodeVertexColor", loc, name) n.layer_name = name return n.outputs["Color"] def stylized_skin(mat, obj=None, base_color=(0.50, 0.26, 0.18), brighter_color=(0.68, 0.40, 0.29), darker_color=(0.36, 0.16, 0.12), lips_color=(0.45, 0.14, 0.12), sss_scale=(0.005, 0.04), base_roughness=0.7, rough_zone=(0.72, 1.0), lips_roughness=0.45, pore_scale=100.0, pore_amount=0.06, noise_mix=0.22, coords="UV", uv_map=None, masks=None): """Kaspar's Snow skin in 5.2 terms. Masks are black/white colour attributes, read only if present on `obj`: col_brighter, col_darker, col_lips, col_sss, col_rough. base colour: start darker, brighten/darken through masks; lips mask removed from both. SSS: Subsurface Weight 1, Subsurface Scale = Map Range(col_sss) over `sss_scale` metres, Random Walk (Skin). His v1 'Subsurface' 0.02..0.15 was a radius multiplier; in 5.2 (Random Walk rescaled) those values turned a 0.33 m head's ears white-pink and waxy in a backlit Cycles test, 0.005..0.04 gave the red glow he asks for (measured, test_skin.py). Scale with character size. roughness: base 0.68..0.72 with pores (two Voronoi Smooth F1 scales, mixed by a detail-0 noise, general noise mixed at ~0.2); col_rough -> 0.72..1; lips glossy. Specular IOR Level stays 0.5: wetness is roughness, not specular. Returns the material.""" mat = get_material(mat) nt = mat.node_tree for n in list(nt.nodes): nt.nodes.remove(n) names = {"brighter": "col_brighter", "darker": "col_darker", "lips": "col_lips", "sss": "col_sss", "rough": "col_rough"} names.update(masks or {}) out = _new(nt, "ShaderNodeOutputMaterial", (1400, 0)) p = _new(nt, "ShaderNodeBsdfPrincipled", (1000, 0)) _link(nt, p.outputs["BSDF"], out.inputs["Surface"]) p.subsurface_method = "RANDOM_WALK_SKIN" p.inputs["Subsurface Weight"].default_value = 1.0 p.inputs["Specular IOR Level"].default_value = 0.5 # ---- base colour block m = {k: _attr_fac(nt, obj, v, (-1200, 600 - i * 180)) for i, (k, v) in enumerate(names.items())} col = base_color not_lips = None if m["lips"] is not None: _, not_lips = _math(nt, "SUBTRACT", 1.0, m["lips"], (-900, 700)) for key, c, blend in (("brighter", brighter_color, "MIX"), ("darker", darker_color, "MIX")): if m[key] is None: continue f = m[key] if not_lips is not None: _, f = _math(nt, "MULTIPLY", f, not_lips, (-700, 600 if key == "brighter" else 450)) _, col = _mix(nt, "RGBA", blend, f, col, c, (-450, 600 if key == "brighter" else 450), key) if m["lips"] is not None: _, col = _mix(nt, "RGBA", "MIX", m["lips"], col, lips_color, (-200, 500), "lips") _feed(nt, p.inputs["Base Color"], col) # ---- SSS amount block (mask stays black/white, Map Range sets the real values) if m["sss"] is not None: _, s = _maprange(nt, m["sss"], 0.0, 1.0, sss_scale[0], sss_scale[1], loc=(300, -150), label="sss scale") _link(nt, s, p.inputs["Subsurface Scale"]) else: p.inputs["Subsurface Scale"].default_value = (sss_scale[0] + sss_scale[1]) * 0.5 # ---- roughness block: pores in roughness, not in bump if coords == "UV": cn = _new(nt, "ShaderNodeUVMap", (-1400, -500)) if uv_map: cn.uv_map = uv_map vec = cn.outputs["UV"] else: vec = _new(nt, "ShaderNodeTexCoord", (-1400, -500)).outputs["Object"] v1 = _new(nt, "ShaderNodeTexVoronoi", (-1100, -400), "pores 1", feature="SMOOTH_F1") v2 = _new(nt, "ShaderNodeTexVoronoi", (-1100, -650), "pores 2", feature="SMOOTH_F1") v1.inputs["Scale"].default_value = pore_scale v2.inputs["Scale"].default_value = pore_scale * 2 nsz = _new(nt, "ShaderNodeTexNoise", (-1100, -900), "pore size mix") nsz.inputs["Detail"].default_value = 0.0 nsz.inputs["Scale"].default_value = pore_scale * 0.05 ngen = _new(nt, "ShaderNodeTexNoise", (-1100, -1150), "general noise") ngen.inputs["Scale"].default_value = pore_scale * 0.5 for n in (v1, v2, nsz, ngen): _link(nt, vec, n.inputs["Vector"]) _, pores = _mix(nt, "FLOAT", fac=nsz.outputs["Fac"], a=v1.outputs["Distance"], b=v2.outputs["Distance"], loc=(-850, -500)) _, pores = _mix(nt, "FLOAT", fac=noise_mix, a=pores, b=ngen.outputs["Fac"], loc=(-650, -500)) # pore centres (low distance) glossier, cracks rougher _, rough = _maprange(nt, pores, 0.0, 1.0, base_roughness - pore_amount, base_roughness + pore_amount, loc=(-450, -500), label="pore roughness") if m["rough"] is not None: _, zone = _maprange(nt, m["rough"], 0.0, 1.0, rough_zone[0], rough_zone[1], loc=(-450, -250), label="rough zones") _, rough = _mix(nt, "FLOAT", fac=m["rough"], a=rough, b=zone, loc=(-200, -400)) if m["lips"] is not None: _, rough = _mix(nt, "FLOAT", fac=m["lips"], a=rough, b=lips_roughness, loc=(0, -400)) _link(nt, rough, p.inputs["Roughness"]) mat.diffuse_color = tuple(base_color) + (1.0,) # Workbench / animator solid view return mat def stylized_eye(mat, iris_color=(0.10, 0.20, 0.33), sclera_color=(0.80, 0.74, 0.70), sclera_edge=(0.72, 0.50, 0.45), iris_radius=0.45, pupil_radius=0.18, radius=1.0, forward="-Y", streaks=True, iris_metallic=0.6): """Procedural stylized eye on an eyeball whose ORIGIN is the eyeball centre and whose front pole looks along `forward` (object axes). Kaspar: spherical gradient in object space (here centred on the front pole), Linear Light noise to break the circles, darker limbal rim, radial iris streaks, off-white sclera tinted to flesh at the edges, black pupil without specular. iris_radius/pupil_radius are fractions of `radius`. One object per eye (mirror modifier removed) or the gradient is shared and wrong.""" mat = get_material(mat) nt = mat.node_tree for n in list(nt.nodes): nt.nodes.remove(n) out = _new(nt, "ShaderNodeOutputMaterial", (1500, 0)) p = _new(nt, "ShaderNodeBsdfPrincipled", (1150, 0)) _link(nt, p.outputs["BSDF"], out.inputs["Surface"]) axis = {"-Y": (0, -1, 0), "Y": (0, 1, 0), "-X": (-1, 0, 0), "X": (1, 0, 0), "Z": (0, 0, 1), "-Z": (0, 0, -1)}[forward] tc = _new(nt, "ShaderNodeTexCoord", (-1500, 0)) # distance from the front pole, normalised by radius: 0 at pole sub = _new(nt, "ShaderNodeVectorMath", (-1300, 0), operation="SUBTRACT") _link(nt, tc.outputs["Object"], sub.inputs[0]) sub.inputs[1].default_value = tuple(a * radius for a in axis) ln = _new(nt, "ShaderNodeVectorMath", (-1100, 0), operation="LENGTH") _link(nt, sub.outputs["Vector"], ln.inputs[0]) _, d = _math(nt, "DIVIDE", ln.outputs["Value"], radius, (-900, 0)) # break the circle without moving it: Linear Light with low-contrast noise nz = _new(nt, "ShaderNodeTexNoise", (-1100, -250)) nz.inputs["Scale"].default_value = 6.0 / radius _link(nt, tc.outputs["Object"], nz.inputs["Vector"]) _, dn = _mix(nt, "FLOAT", fac=0.06, a=d, b=nz.outputs["Fac"], loc=(-700, 0)) _, iris_m = _maprange(nt, dn, iris_radius, iris_radius - 0.03, interp="SMOOTHSTEP", loc=(-500, 150), label="iris mask") _, pupil_m = _maprange(nt, dn, pupil_radius, pupil_radius - 0.015, interp="SMOOTHSTEP", loc=(-500, -50), label="pupil mask") _, rim = _maprange(nt, dn, iris_radius * 0.72, iris_radius, 0.0, 1.0, interp="SMOOTHSTEP", loc=(-500, 350), label="limbal rim") iris_c = iris_color if streaks: # radial streaks: polar coordinates around the forward axis sep = _new(nt, "ShaderNodeSeparateXYZ", (-1100, -500)) _link(nt, sub.outputs["Vector"], sep.inputs[0]) a_idx = [i for i in range(3) if axis[i] == 0] _, ang = _math(nt, "ARCTAN2", sep.outputs[a_idx[0]], sep.outputs[a_idx[1]], (-900, -500)) cmb = _new(nt, "ShaderNodeCombineXYZ", (-700, -500)) _, angs = _math(nt, "MULTIPLY", ang, 12.0, (-800, -650)) _link(nt, angs, cmb.inputs[0]) _, ds = _math(nt, "MULTIPLY", d, 3.0, (-800, -800)) _link(nt, ds, cmb.inputs[1]) vo = _new(nt, "ShaderNodeTexNoise", (-500, -500)) vo.inputs["Scale"].default_value = 2.0 vo.inputs["Detail"].default_value = 4.0 _link(nt, cmb.outputs[0], vo.inputs["Vector"]) _, st = _maprange(nt, vo.outputs["Fac"], 0.35, 0.75, loc=(-300, -500)) light = tuple(min(1.0, c * 2.2 + 0.05) for c in iris_color) _, iris_c = _mix(nt, "RGBA", "MIX", st, iris_color, light, (-100, -300), "iris streaks") rim_dark = tuple(c * 0.35 for c in iris_color) _, iris_c = _mix(nt, "RGBA", "MIX", rim, iris_c, rim_dark, (100, -100), "limbal rim") # sclera: never pure white, flesh toward the edges (far from the pole) _, edge_t = _maprange(nt, d, 0.8, 1.4, interp="SMOOTHSTEP", loc=(-300, 500)) _, scl = _mix(nt, "RGBA", "MIX", edge_t, sclera_color, sclera_edge, (100, 500), "sclera") _, col = _mix(nt, "RGBA", "MIX", iris_m, scl, iris_c, (350, 300), "iris") _, col = _mix(nt, "RGBA", "MIX", pupil_m, col, (0.003, 0.003, 0.003), (550, 300), "pupil") _link(nt, col, p.inputs["Base Color"]) _, met = _math(nt, "MULTIPLY", iris_m, iris_metallic, (350, -300)) _, met = _mix(nt, "FLOAT", fac=pupil_m, a=met, b=0.0, loc=(550, -300)) _link(nt, met, p.inputs["Metallic"]) _, spec = _mix(nt, "FLOAT", fac=pupil_m, a=0.5, b=0.0, loc=(550, -500)) # no highlight in the pupil _link(nt, spec, p.inputs["Specular IOR Level"]) p.inputs["Roughness"].default_value = 0.35 mat.diffuse_color = tuple(sclera_color) + (1.0,) return mat def cornea_material(mat, ior=1.376, roughness=0.02): """Clear cornea shell: Transmission 1, low roughness, Material Output Thickness 0 (SLAB). Without the zero thickness EEVEE refracts the shell as a solid glass ball and magnifies the iris across the eye; Cycles is right either way (verified). Keep material.use_raytrace_refraction on (off: the eye vanishes behind a probe reflection) and scene.eevee.use_raytracing on. Pair with obj.visible_shadow = False (Kaspar: the cornea casts no shadow; honoured by EEVEE and Cycles, verified). ior: human cornea [added].""" mat = get_material(mat) p = principled(mat) out = output_node(mat) th = out.inputs["Thickness"] if not th.is_linked: # EEVEE treats a closed transmissive shell as a solid lens z = mat.node_tree.nodes.new("ShaderNodeValue") z.location = (out.location.x - 200, out.location.y - 300) z.label = "thin shell" z.outputs[0].default_value = 0.0 mat.node_tree.links.new(z.outputs[0], th) mat.thickness_mode = "SLAB" p.inputs["Transmission Weight"].default_value = 1.0 p.inputs["Roughness"].default_value = roughness p.inputs["IOR"].default_value = ior p.inputs["Base Color"].default_value = (1, 1, 1, 1) mat.use_raytrace_refraction = True mat.surface_render_method = "DITHERED" return mat # -------------------------------------------------------------------------------------- # decals # -------------------------------------------------------------------------------------- def add_decal(mat, obj, image, center, normal, up=(0, 0, 1), size=0.2, uv_name="decal", max_angle=70.0): """Kaspar's logo method, scripted: a dedicated UV map projected along -normal onto the faces that face it within `size`, every other loop parked outside 0..1; the image node uses Extension CLIP so parked loops read alpha 0. Mixes the decal over Base Color by its alpha. `image` is a path or bpy Image (sRGB). Returns the mix node.""" mat = get_material(mat) img = image if isinstance(image, bpy.types.Image) else load_image(image, "base_color") img.alpha_mode = "CHANNEL_PACKED" me = obj.data uvl = me.uv_layers.get(uv_name) or me.uv_layers.new(name=uv_name) mw = obj.matrix_world n = Vector(normal).normalized() u = Vector(up).cross(n) if u.length < 1e-6: u = Vector((1, 0, 0)).cross(n) u.normalize() v = n.cross(u) c = Vector(center) cos_max = math.cos(math.radians(max_angle)) nmat = mw.to_3x3().inverted_safe().transposed() uv = np.full((len(me.loops), 2), -2.0, dtype=np.float32) for poly in me.polygons: pn = (nmat @ poly.normal).normalized() if pn.dot(n) < cos_max: continue for li in poly.loop_indices: w = mw @ me.vertices[me.loops[li].vertex_index].co d = w - c uu, vv = d.dot(u) / size + 0.5, d.dot(v) / size + 0.5 if abs(d.dot(n)) > size: continue uv[li] = (uu, vv) uvl.uv.foreach_set("vector", uv.ravel()) nt = mat.node_tree p = principled(mat) un = _new(nt, "ShaderNodeUVMap", (p.location.x - 900, p.location.y + 700), uv_name) un.uv_map = uv_name tx = _new(nt, "ShaderNodeTexImage", (p.location.x - 700, p.location.y + 700), "decal") tx.image, tx.extension = img, "CLIP" _link(nt, un.outputs["UV"], tx.inputs["Vector"]) base = _source(nt, p.inputs["Base Color"], (p.location.x - 700, p.location.y + 400)) mix, res = _mix(nt, "RGBA", "MIX", tx.outputs["Alpha"], base, tx.outputs["Color"], (p.location.x - 300, p.location.y + 500), "decal over") _link(nt, res, p.inputs["Base Color"]) return mix # -------------------------------------------------------------------------------------- # vertex masks (colour attributes) # -------------------------------------------------------------------------------------- def _mesh_arrays(obj): me = obj.data co = np.empty(len(me.vertices) * 3, dtype=np.float64) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) ev = np.empty(len(me.edges) * 2, dtype=np.int64) me.edges.foreach_get("vertices", ev) return co, ev.reshape(-1, 2) def _vertex_normals(obj): me = obj.data n = np.empty(len(me.vertices) * 3, dtype=np.float64) me.vertex_normals.foreach_get("vector", n) return n.reshape(-1, 3) def _neighbor_mean(values, ev, nv): acc = np.zeros((nv,) + values.shape[1:]) cnt = np.zeros(nv) np.add.at(acc, ev[:, 0], values[ev[:, 1]]) np.add.at(acc, ev[:, 1], values[ev[:, 0]]) np.add.at(cnt, ev[:, 0], 1) np.add.at(cnt, ev[:, 1], 1) cnt = np.maximum(cnt, 1) return acc / cnt.reshape((-1,) + (1,) * (values.ndim - 1)) def write_vertex_values(obj, name, values, domain="POINT"): """Write a per-vertex scalar (0..1) into a FLOAT_COLOR attribute (grey).""" me = obj.data attr = me.color_attributes.get(name) if attr is not None and (attr.domain != domain or attr.data_type != "FLOAT_COLOR"): me.color_attributes.remove(attr) attr = None if attr is None: attr = me.color_attributes.new(name=name, type="FLOAT_COLOR", domain=domain) v = np.clip(np.asarray(values, dtype=np.float32), 0, 1) if domain == "CORNER": lv = np.empty(len(me.loops), dtype=np.int64) me.loops.foreach_get("vertex_index", lv) v = v[lv] rgba = np.repeat(v[:, None], 4, axis=1) rgba[:, 3] = 1.0 attr.data.foreach_set("color", rgba.ravel()) return attr def read_vertex_values(obj, name): attr = obj.data.color_attributes[name] a = np.empty(len(attr.data) * 4, dtype=np.float32) attr.data.foreach_get("color", a) return a.reshape(-1, 4)[:, 0] def curvature_attribute(obj, name="col_curvature", smooth=2, contrast=4.0): """Vertex curvature as a colour attribute: 0.5 flat, > 0.5 convex, < 0.5 concave (Laplacian along the normal, scaled by mean edge length). Engine independent: the EEVEE/game substitute for Bevel-node edge masks and Cycles-only Pointiness. Depends on vertex density like Pointiness: on a low poly, bake from the high instead.""" co, ev = _mesh_arrays(obj) nrm = _vertex_normals(obj) nv = len(co) lap = _neighbor_mean(co, ev, nv) - co el = np.linalg.norm(co[ev[:, 0]] - co[ev[:, 1]], axis=1) le = np.zeros(nv) ce = np.zeros(nv) np.add.at(le, ev[:, 0], el) np.add.at(le, ev[:, 1], el) np.add.at(ce, ev[:, 0], 1) np.add.at(ce, ev[:, 1], 1) le = le / np.maximum(ce, 1) k = -(lap * nrm).sum(1) / np.maximum(le, 1e-9) # convex -> positive for _ in range(smooth): k = 0.5 * k + 0.5 * _neighbor_mean(k, ev, nv) val = 0.5 + 0.5 * np.tanh(k * contrast) write_vertex_values(obj, name, val) return val def mask_from_points(obj, name, points, falloff="SMOOTH", combine="MAX", base=0.0, smooth=0): """Colour-attribute mask from world-space landmarks: points = [(co, radius, value)]. The substitute for painting a black/white mask by hand (Kaspar paints col_sss, col_brighter...). combine MAX or ADD. Returns the per-vertex values.""" co, ev = _mesh_arrays(obj) mw = np.array(obj.matrix_world) wco = co @ mw[:3, :3].T + mw[:3, 3] val = np.full(len(co), base, dtype=np.float64) for c, r, v in points: d = np.linalg.norm(wco - np.asarray(c), axis=1) / max(r, 1e-9) t = np.clip(1 - d, 0, 1) if falloff == "SMOOTH": t = t * t * (3 - 2 * t) contrib = t * v val = np.maximum(val, contrib) if combine == "MAX" else val + contrib for _ in range(smooth): val = 0.5 * val + 0.5 * _neighbor_mean(val, ev, len(co)) write_vertex_values(obj, name, np.clip(val, 0, 1)) return val def smooth_attribute(obj, name, iterations=4, factor=0.5): """Laplacian blur of a POINT colour attribute (the headless 'blur brush').""" co, ev = _mesh_arrays(obj) v = read_vertex_values(obj, name).astype(np.float64) for _ in range(iterations): v = (1 - factor) * v + factor * _neighbor_mean(v, ev, len(co)) write_vertex_values(obj, name, v) return v # -------------------------------------------------------------------------------------- # UV-space painting (headless substitute for texture-paint strokes) # -------------------------------------------------------------------------------------- class TexelMap: """World position, world normal and validity of every texel of a res x res image, rasterised from `uv_layer` (default: active render UV). Array layout matches image.pixels: row 0 is the BOTTOM of the image (v = 0). Strokes painted by world distance through this map are seam-proof: a dab that crosses a UV seam lands on both islands, unlike a stroke drawn in UV space.""" def __init__(self, obj, res, uv_layer=None, use_modifiers=False): self.res = res dg = bpy.context.evaluated_depsgraph_get() src = obj.evaluated_get(dg) if use_modifiers else obj me = src.to_mesh() if use_modifiers else obj.data try: me.calc_loop_triangles() nt = len(me.loop_triangles) tl = np.empty(nt * 3, dtype=np.int64) me.loop_triangles.foreach_get("loops", tl) tl = tl.reshape(-1, 3) tv = np.empty(nt * 3, dtype=np.int64) me.loop_triangles.foreach_get("vertices", tv) tv = tv.reshape(-1, 3) if uv_layer is None: uvl = next((u for u in me.uv_layers if u.active_render), me.uv_layers.active) else: uvl = me.uv_layers[uv_layer] uv = np.empty(len(me.loops) * 2, dtype=np.float64) uvl.uv.foreach_get("vector", uv) uv = uv.reshape(-1, 2) co = np.empty(len(me.vertices) * 3, dtype=np.float64) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) cn = np.empty(len(me.loops) * 3, dtype=np.float64) me.corner_normals.foreach_get("vector", cn) cn = cn.reshape(-1, 3) finally: if use_modifiers: src.to_mesh_clear() mw = np.array(obj.matrix_world) co = co @ mw[:3, :3].T + mw[:3, 3] nm = np.linalg.inv(mw[:3, :3]).T cn = cn @ nm.T cn /= np.maximum(np.linalg.norm(cn, axis=1, keepdims=True), 1e-12) self.pos = np.zeros((res, res, 3), dtype=np.float32) self.nrm = np.zeros((res, res, 3), dtype=np.float32) self.valid = np.zeros((res, res), dtype=bool) self.tri = np.full((res, res), -1, dtype=np.int32) P = uv[tl] * res - 0.5 # pixel-centre coordinates for t in range(len(tl)): p0, p1, p2 = P[t] x0 = int(max(math.floor(min(p0[0], p1[0], p2[0])), 0)) x1 = int(min(math.ceil(max(p0[0], p1[0], p2[0])), res - 1)) y0 = int(max(math.floor(min(p0[1], p1[1], p2[1])), 0)) y1 = int(min(math.ceil(max(p0[1], p1[1], p2[1])), res - 1)) if x1 < x0 or y1 < y0: continue den = (p1[1] - p2[1]) * (p0[0] - p2[0]) + (p2[0] - p1[0]) * (p0[1] - p2[1]) if abs(den) < 1e-12: continue xs, ys = np.meshgrid(np.arange(x0, x1 + 1), np.arange(y0, y1 + 1)) w0 = ((p1[1] - p2[1]) * (xs - p2[0]) + (p2[0] - p1[0]) * (ys - p2[1])) / den w1 = ((p2[1] - p0[1]) * (xs - p2[0]) + (p0[0] - p2[0]) * (ys - p2[1])) / den w2 = 1 - w0 - w1 ins = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4) if not ins.any(): continue yy, xx = ys[ins], xs[ins] W = np.stack([w0[ins], w1[ins], w2[ins]], 1) self.pos[yy, xx] = W @ co[tv[t]] self.nrm[yy, xx] = W @ cn[tl[t]] self.valid[yy, xx] = True self.tri[yy, xx] = t n = np.linalg.norm(self.nrm, axis=2, keepdims=True) self.nrm /= np.maximum(n, 1e-12) self.coverage = float(self.valid.mean()) def new_image(name, res, color=(0, 0, 0, 1), colorspace="Non-Color", float_buffer=False): """Get-or-create a square image filled with `color` (values as stored, see module doc).""" img = bpy.data.images.get(name) if img is None or tuple(img.size) != (res, res) or img.is_float != float_buffer: if img is not None: bpy.data.images.remove(img) img = bpy.data.images.new(name, res, res, alpha=True, float_buffer=float_buffer) img.colorspace_settings.name = colorspace a = np.empty((res, res, 4), dtype=np.float32) a[:] = color img.pixels.foreach_set(a.ravel()) img.update() return img def pixels(img): """image.pixels as an (h, w, 4) float32 array (row 0 = bottom).""" w, h = img.size a = np.empty(w * h * 4, dtype=np.float32) img.pixels.foreach_get(a) return a.reshape(h, w, 4) def write_pixels(img, arr): img.pixels.foreach_set(np.ascontiguousarray(arr, dtype=np.float32).ravel()) img.update() return img def save_image(img, path, file_format=None): """Save to disk (headless scripts have no autosave; Kaspar lost painted maps to this). PNG for byte maps, OPEN_EXR for float data.""" fmt = file_format or ("OPEN_EXR" if path.lower().endswith(".exr") else "PNG") img.filepath_raw = path img.file_format = fmt img.save() return path def _dab_weights(dist, radius, hardness): t = np.clip(1 - dist / radius, 0, 1) if hardness >= 1: return (dist <= radius).astype(np.float32) # smoothstep falloff sharpened by hardness s = np.clip(t / max(1 - hardness, 1e-3), 0, 1) return s * s * (3 - 2 * s) def paint_sphere(arr, tm, center, radius, color=(1, 1, 1, 1), strength=1.0, hardness=0.3, normal_cut=None): """One world-space dab: texels within `radius` of `center` blend toward `color`. normal_cut: dot(texel normal, normal_cut) > 0 required (skip the far side of thin parts).""" c = np.asarray(center, dtype=np.float32) lo, hi = c - radius, c + radius box = tm.valid & np.all((tm.pos >= lo) & (tm.pos <= hi), axis=2) if not box.any(): return 0 yy, xx = np.nonzero(box) d = np.linalg.norm(tm.pos[yy, xx] - c, axis=1) w = _dab_weights(d, radius, hardness) * strength if normal_cut is not None: w = w * (tm.nrm[yy, xx] @ np.asarray(normal_cut, dtype=np.float32) > 0) col = np.asarray(color, dtype=np.float32) arr[yy, xx] = arr[yy, xx] * (1 - w[:, None]) + col * w[:, None] return int((w > 0).sum()) def paint_stroke(arr, tm, points, radius, color=(1, 1, 1, 1), strength=1.0, hardness=0.3, spacing=0.3): """Dabs along a world-space polyline, spaced `spacing * radius` apart (a brush stroke).""" pts = [np.asarray(p, dtype=np.float64) for p in points] dabs = [pts[0]] for a, b in zip(pts, pts[1:]): seg = np.linalg.norm(b - a) k = max(1, int(seg / (spacing * radius))) dabs += [a + (b - a) * (i / k) for i in range(1, k + 1)] touched = 0 for d in dabs: touched += paint_sphere(arr, tm, d, radius, color, strength, hardness) return touched def project(tm, fn, fill=0.0): """Evaluate fn(P, N) -> (h, w) values on every valid texel (P, N world position and normal arrays of shape (h, w, 3)). The general 'project a 3D mask into the texture'.""" v = np.asarray(fn(tm.pos, tm.nrm), dtype=np.float32) return np.where(tm.valid, v, fill).astype(np.float32) def vertex_values_to_texels(obj, values, tm): """Rasterise a per-vertex scalar (e.g. curvature_attribute output) into texel space by barycentric interpolation, reusing the TexelMap triangle ids.""" me = obj.data me.calc_loop_triangles() tv = np.empty(len(me.loop_triangles) * 3, dtype=np.int64) me.loop_triangles.foreach_get("vertices", tv) tv = tv.reshape(-1, 3) vals = np.asarray(values, dtype=np.float64) out = np.zeros((tm.res, tm.res), dtype=np.float32) ok = tm.valid t = tm.tri[ok] # recover barycentrics from positions (least squares on the triangle's vertices) mw = np.array(obj.matrix_world) co = np.empty(len(me.vertices) * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) @ mw[:3, :3].T + mw[:3, 3] A = co[tv[t]] # (n, 3, 3) triangle corners p = tm.pos[ok].astype(np.float64) v0, v1 = A[:, 1] - A[:, 0], A[:, 2] - A[:, 0] v2 = p - A[:, 0] d00, d01, d11 = (v0 * v0).sum(1), (v0 * v1).sum(1), (v1 * v1).sum(1) d20, d21 = (v2 * v0).sum(1), (v2 * v1).sum(1) den = np.maximum(d00 * d11
-
-
SKILL.md 15.3 KB
--- name: scenario-blender-texturing-shading description: "Use when texturing, shading or look-developing in Blender: building a PBR material from a texture set, displacement, procedural materials, edge wear, dirt, grime, color variation, decals, box or triplanar mapping on meshes without UVs, stylized character skin (subsurface, pores), eyes and cornea, hand-painted stylized textures, texture painting or painting masks headless. Also when a material looks plastic, CG, waxy or blown out, color spaces are wrong, or EEVEE and Cycles disagree." license: MIT --- # Texturing and shading (Blender 5.2) Expert shading is layered masks feeding physically plausible values: a clean base (scanned set or flat colors), then contrasty black/white masks that place color, roughness, height and subsurface, all editable in nodes. Values are judged under honest light in both engines, and baking comes last, for portability. 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). Baking maps: scenario-blender-uv-baking. Lighting and final renders: scenario-blender-lighting-rendering. Toolkit: [`scripts/bx_materials.py`](scripts/bx_materials.py) (builders, headless painting, checks; exercised by `tests/code/blender-texturing-shading/`). `sys.path.append("<this skill>/scripts"); import bx_materials as M`. ## Stance (the expert delta) - **Data maps are Non-Color, "not what looks better, what's correct"** (Price). Only base color and emission are sRGB. - **Normal map OR displacement, never both** (Price, confirmed by Cycles lead Brecht): they encode the same relief. Displacement when heights will be combined or truly displaced; normal map for export and 8-bit safety. - **Masks, not painted color.** Kaspar and Price build every effect as a grayscale mask into a Mix or HSV Factor; color lives in the A/B inputs. Masks need contrast (Color Ramp stops pulled in) or they barely show. The same masks drive roughness, height and SSS. - **Realism: scanned set plus masks; stylized: procedural** (Price vs Kaspar and Ryan King). Kaspar strips photos to their pattern (blur plus grain extract) and adds grunge himself, and bakes at the end for portability (export, EEVEE load time), not for Cycles speed. - **Micro detail in roughness, not bump**, for skin and hair (Kaspar); edit roughness through HSV Value, never by unplugging the map (Price). Specular stays 0.5. Bump Distance is the real height of the map's 0..1: lower Distance, not Strength (Kaspar). - **Judge honestly:** Standard view, neutral mid-gray background, Base Color black to read roughness, a hard backlight and a light from below (Kaspar); grazing light for relief (Price); unlit for hand-painted albedo (Abbitt). EEVEE for iteration, Cycles as the truth, and they must match (Kaspar). - **SSS follows anatomy** (Kaspar): high on ears, nose, lids, lips, fingertips; low over bone. Black/white mask, Map Range sets values. - **Bevel, AO, Pointiness and Random Per Island are Cycles-only, and Bevel/AO are stochastic** [added, measured]: EEVEE shows no Bevel edges; thresholded live masks give 17 to 18% fractional metallic at any sample count. Bake before thresholding, or use vertex curvature. ## Establish first | Input | Changes | Default when silent | | ---------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------- | | Target: Cycles still, EEVEE, game engine | live Cycles-only nodes vs baked; AO in albedo (render) vs ORM (game) | Cycles finals, EEVEE side by side | | Style: realistic or stylized | scanned set + grime vs procedural or hand-painted light | stylized restraint (Kaspar) | | UVs and deformation | UV images vs box projection vs Object procedurals | rigid, no UVs: box; deforming: UV only | | Scale | tile size, bump/displacement meters | real meters, scale applied (all experts) | | Texel budget, export | 2K to 4K hero paint, 4K to 8K large tiling surfaces | 2K hero prop, glTF ORM, OpenGL normals | ## Workflow 1. **Base per part.** Prefixed materials, placeholders removed, `diffuse_color` set; values picked on the Diffuse Color pass (matcap made Kaspar's skin too pale). Apply scale. GATE: `M.value_report(objs)` clean. 2. **Base material.** Set: `M.pbr_from_textures(mat, M.find_texture_set(dir), uv_map=...)`: color spaces by role, AO multiplied at factor 1 (Price: restores the cavity shadow flash capture removes; `ao_mode="gltf"` for games), bump only without a normal map, `height_as="displacement"` drops the normal map and sets Cubic interpolation, Displacement and Bump, Scale 0.2 for Poliigon (fixed 20 cm range); add Simple subdivision and shade smooth. UV tile = the set's physical size. Rigid, no UVs: `projection="BOX"`. Procedural: Object coordinates (UV on deforming meshes), Noise Detail at most 5 (cost doubles per level, Price). GATE: `M.audit_material(mat, obj)` has no errors. 3. **Masks.** Kaspar's rule is mask frequency against vertex density: vertex colors for broad soft masks (and Price's environments: tiling UVs repeat image strokes, layouts change), images for fine or exported detail, UV bands for hems. Headless: `M.mask_from_points`, `M.write_vertex_values`, `M.smooth_attribute`, `M.curvature_attribute`, `M.TexelMap` + `M.project`. Price's Color Burn: soft mask (A) burned by a contrast-ramped height map (B) breaks the mask along bricks or into grout (procedures P18). GATE: render each mask as emission. 4. **Layers.** `M.add_wear(mat, edge_color=..., edge_roughness=..., edge_metallic=1)` adds variation, cavity/grunge dirt and chipped edges. Blend modes: Multiply darkens (grime, mold), Screen lightens (efflorescence, highlights; Add exceeds 1), Subtract on the height chain for spalling (Price). Chain order sets age (a decal before grime reads old). Decals: Alpha into Factor, Extension Clip (`M.add_decal`). GATE: `M.preview(objs, out, rigs=("studio", "roughness"))`: effects follow form and water paths, nothing uniform, repeats broken. 5. **Character specifics.** `M.stylized_skin(mat, obj=body)` (masks `col_brighter/col_darker/col_lips/col_sss/col_rough`); `M.stylized_eye` per eye object (origin at the eyeball center) plus `M.cornea_material` and `cornea.visible_shadow = False`; hair shine: Anisotropic with a Tangent on a strand-aligned UV map (Cycles only). GATE: `rigs=("backlit",)`: red glow on ears, nostrils, lips, fingers; front not waxy; sclera off-white. 6. **Stylized hand-painted light** (Abbitt): lighter top, darker bottom, Multiply in crevices, Screen on extremities. Headless: `M.painted_light(mat, obj)` from a curvature attribute, bake Emit, judge with `rigs=("unlit",)`. 7. **Painting, where masks cannot place it.** GUI: brushes are assets (`Paint Hard/Soft`, `Airbrush`, `Blur`, `Smear`, `Fill`, `Mask`); a factory 5.2 session paints BLACK until the brush and unified color are set; strokes project from the screen, so face the surface (Abbitt); Erase needs an alpha channel; bleed 8 to 12 px (Kaspar, Abbitt). Use `M.gui_texture_stroke()`: it forces a redraw (projection painting occludes with the depth buffer of the previous draw: a stroke issued in the setup call painted 162 texels in one run instead of about 14,000, full coverage in another) and resamples dabs. Headless: `M.TexelMap` + `M.paint_stroke` paints by world distance, seams included; `M.dilate`, then `M.save_image` (images are never saved with the .blend by a script). GATE: renders from views crossing seams. 8. **Lookdev check and bake.** `M.preview` (EEVEE and Cycles, studio, backlit, roughness), `M.value_report`, labeled frames and node groups (Kaspar, Price). Bake procedural channels via Emission, 30+ samples, validate by rebuilding from the maps (scenario-blender-uv-baking). ## Numbers | Value | Number | Source | | ------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------ | | Dielectric albedo / metal reflectance | sRGB about 30 to 240 / 180 to 255; metallic 0 or 1 | ranges [added]; binary metallic: Ryan King, Price | | Skin roughness | 0.68 to 0.72, rough zones 0.72 to 1, lips and mouth about 0.4 | Kaspar | | Pores | Voronoi Smooth F1 scales 100 and 200, noise mix 0.2 to 0.25 | Kaspar, on his UVs | | Skin SSS scale (5.2) | 0.005 to 0.04 m | measured on a 0.33 m head; Kaspar's v1 0.02 to 0.15 is waxy in 5.2 | | Bump | Distance about 0.001, Strength 0.1 to 0.2 for fabric | Kaspar | | Displacement | Scale 0.2 (Poliigon), Midlevel 0.5, Cubic, 16-bit source | Price | | Brick wall roughness | about 0.7 | Price | | Color tweak | Hue within 0.5 plus or minus 0.04 | Price | | Noise Detail | 5 or less | Price | | Edge-wear Bevel radius | 0.04 on a 1 m crate | measured | | Paint bleed / island margin | 8 to 12 px / 0.01 to 0.05 | Kaspar, Abbitt | | Painted height | 2K, 32-bit float, base 0.5, Non-Color | Kaspar; 0.5 gray base also Ryan King | | Bake samples | 30 minimum | Kaspar | ## Quality gates Measurable: - `M.audit_material(mat, obj)`: color spaces by role, no image straight into Normal, explicit UV maps, Bump Distance at most 0.05, no Add Shader or raw summed normals, Emission Strength set, no gray constant metallic, normal map plus displacement flagged, non-Cubic displacement image, Noise Detail above 6, unsaved images. - `M.value_report(objs)`: dielectric albedo within 30 to 243 sRGB (under 2% outside), metal above 180, fractional metallic under 5% (live Bevel/AO wear exceeds it until baked), roughness std above 0.01. - Scale applied; color attributes named `col_*` and matching every Attribute node exactly (Kaspar, Price). Visual: `M.preview` EEVEE vs Cycles nearly identical; roughness row shows breakup; backlit glow only where flesh is thin; grazing light shows relief without terracing; repeats broken at distance; per-layer mute renders each add something (Price); scale against a human reference. ## Common mistakes | Mistake | Looks like | Fix | | ------------------------------------------------------------ | ----------------------------------- | ----------------------------------- | | Data map left sRGB | bent bump, wrong gloss | Non-Color | | Normal map and displacement together (Node Wrangler default) | doubled or broken relief | keep one | | Displacement with no geometry or Catmull-Clark | nothing, or rounded corners | Simple subdivision, shade smooth | | Pre-4.5 Bump Distance 1.0 | stepped, plastic | real height, about 0.001 to 0.01 | | Emission Color set, Strength 0 | no glow | Strength above 0 | | Kaspar's v1 SSS values in 5.2 | waxy white ears | 0.005 to 0.04, check backlit | | Bevel wear or Pointiness in EEVEE or games | wear missing | bake, or `curvature_attribute` | | Threshold on live Bevel/AO for metallic | grainy gray borders | bake, then threshold | | Cornea shell in EEVEE, default thickness | iris magnified | Thickness 0 (`cornea_material`) | | Mask straight from Noise | effect barely visible | ramp or Map Range for contrast | | Stroke right after setup | partial or no paint, varies per run | redraw first (`gui_texture_stroke`) | | Paint color never set / new color attribute | black strokes / white mask | set colors; write zeros first | | Box projection or Generated coords on a rig | texture swims | UV coordinates | ## Blender 5.2 notes - Principled v2: Subsurface Weight/Scale (no Subsurface Color), Specular IOR Level, Transmission Weight, Coat, Emission Strength default 0. Sockets by name; Mix node by index (RGBA: A=6, B=7, Result=2). - Color spaces `sRGB`, `Non-Color`, `Linear Rec.709`. Displacement node Scale default 0.01; `material.displacement_method` (not `material.cycles`); EEVEE displaces since 4.2 (Displacement and Bump). - `use_nodes` deprecated; `surface_render_method` replaces `blend_method`; refraction needs `use_raytrace_refraction` plus scene ray tracing. - EEVEE ignores anisotropy, Bevel, Pointiness, Random Per Island (verified). Random Walk SSS rescaled in 5.2. Musgrave is inside Noise. - Brushes are assets; custom brushes need "Save Changes to Asset" (Abbitt); `image_brush_type`; Blur is `SOFTEN`; sample color `Shift+X`. - `paint.vertex_color_dirt` works headless in Object mode; byte images store encoded values, float `Linear Rec.709` images convert to sRGB when saved as PNG. ## References - [`references/procedures.md`](references/procedures.md): tested code for every stage (texture sets, displacement rule, box projection, wear, curvature, decals, skin, eyes, anisotropy, GUI and headless painting, high-pass, checks, camera-ray world, clay switch, painted light, Color Burn masks, physical UV scale). - [`references/expert-notes.md`](references/expert-notes.md): reasoning per expert with timestamps and the disagreements with their deciding conditions. - [`references/critique.md`](references/critique.md): rubric for judging your own renders and node trees. - [`references/sources.md`](references/sources.md): every source video, credential, best timestamps.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.