scenario-blender-grease-pencil
Use when drawing or animating with Grease Pencil in Blender 5.x from Python: 2D or 2.5D illustration, frame-by-frame animation, a cutout or part-based 2D character, strokes with pressure, filled shapes with holes, in-betweens and interpolation, onion skin, Line Art outlines, stro
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-grease-pencil
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
Grease Pencil (v3, Blender 5.2)
Expert Grease Pencil work keeps every drawing editable and redrawable, puts meaning in the structure (which object, which layer, which key) and judges the result in motion. The agent draws through the data API (drawings are curves with attributes), builds characters the way 2D studios rig them, and reviews rendered frames instead of a viewport it cannot flip. 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). Shot planning, boards and animatics: scenario-blender-previs-storyboard. Mesh animation principles: scenario-blender-animation.
Toolkit: scripts/bx_gp.py (tested on 5.2.1). import sys; sys.path.append("<this skill>/scripts"); import bx_gp as G.
Stance (the expert delta)
- Tom Viguier (Andarta, Ewilan's Quest): the rig must never block redrawing. One GP object per physical element (about 60 for a lead character), parented, mostly FK by object transforms; layers inside each element by graphic logic (line above color); sculpting as the deformer; no armature weight painting on drawings, because every redraw would need new weights.
- Tom: interpolate only "sculpted" pairs. Two keys interpolate cleanly only with the same number, order and color of strokes and the same points. Otherwise redraw, or "clip" to the nearest drawing instead of accepting distortion. Native interpolation ignores object animation: key transforms separately.
- Falk David (GP module lead): fill is geometry, not material. A stroke fills when
fill_id != 0; strokes sharing an id fill together with even-odd holes;hide_strokehides the outline. Material fill toggles are deprecated and holdout "eraser fills" (Grant Abbitt's 4.4 method) are obsolete. - Grant Abbitt: the layer stack carries meaning and materials are paint buckets. Lines on top, a shadow layer (Multiply, lowered opacity, masked by the fills) in between, fills at the bottom. Editing a material recolors every stroke using it. Lock what you are not editing: erasers and sculpt act on all unlocked layers.
- Elijah Sheffield: predictable color first. Standard view transform, flat unlit shading, white world. Verified trap: new layers have
use_lights=True, and lit layers take their ambient fromWorld.color(default 0.05), not from the world node tree, so a flat palette renders at about a quarter of its value. - Tom: multiplane discipline and layout first. Every 2D element parallel to the camera, a camera that never changes axis, 3D only where it saves time; camera, cuts and parallax are settled in layout, not discovered in compositing. Between objects, depth decides who draws on top.
- Michael Weigl: motion reveals the surface. A painterly still breaks once it moves; judge 2.5D as a sequence. Sort groups of strokes (mesh islands), never individual strokes, and bake procedural output back to editable strokes.
- Pablo Fournier (Blender Studio): design the shadows. Replace blobby 3D shading with blocky, drawn light and shadow shapes (a light and a shadow material per part). Fills are flat: use many small fills on curved surfaces, not one big fill pushed forward.
Establish first
| Input | Changes | Default when silent |
|---|---|---|
| Deliverable | still, frame-by-frame shot, cutout rig, hybrid motion graphics, 2.5D over 3D | frame-by-frame shot |
| Format | resolution, fps, length | 1920 x 1080, 24 fps keyed on twos [added]; social motion graphics: 1080 x 1920, 12 fps, 60 frames (Elijah) |
| Look | flat or lit, line system | flat (unlit layers, Standard); ink line with pressure taper |
| Line system | PEN constant width (matches Line Art, Elijah) or INK_PEN pressure on radius (cartoony, Grant) or PENCIL (pressure on radius and opacity) | INK_PEN |
| Palette | material colors | hex sRGB values, one material per paint bucket |
| Camera | ortho multiplane or perspective | ortho, front view, drawing plane XZ |
| Who edits next | how much stays procedural | a human animator: everything bakes to editable strokes |
Workflow
- Stage and layout.
cam = G.scene_2d(res, fps, frame_range, ortho_scale)(ortho camera at -Y, Standard, background). Plan the multiplane: which elements are separate objects and their depth order. Computepx = G.px_size(); line width in pixels is2 * radius / px. GATE: a test render shows the frame; planned line widths are at least 1.5 px [added]. - Structure.
G.new_object(name, layers=("Fills", "Shadow", "Lines"))per element (bottom to top, unlit), parent parts, place origins at joints.G.material(ob, name, stroke=hex, fill=hex)once per bucket. GATE:[l.name for l in ob.data.layers]matches intent;G.draw_order(objs)lists front to back as designed. - Draw. Keys first:
d = G.key(ob, "Lines", frame). Lines:G.add_stroke(d, G.smooth_path(control_pts), radius, pressure=G.taper(n), brush="INK_PEN", material=i); overlap then trim rather than landing ends exactly (Grant). Fills:G.add_shape(d, outline, holes=[...], material=i)on the Fills layer, outline on Lines. Bulk:G.add_strokes(d, specs)(3000 strokes in 0.04 s). Fix shapes withG.grab,G.dissolve,G.edit_op(ob, "stroke_smooth", ...). GATE:G.verdict(G.report(ob))is clean and a full-resolutionG.render_frameshows no gaps, leaks or overhangs. - Color and shadow. Shadow layer:
blend_mode='MULTIPLY', opacity lowered,use_masks=True,mask_layers.add(fills); paint the shadow shape past the edge and let the mask clip it; design it blocky (Pablo). Lit look:use_lights=TrueplusWorld.coloras ambient plus lamps (verified: a sun only scales value on a flat drawing, a point light gives a gradient). GATE: sampled pixels match the palette under Standard. - Animate. Replacement key
G.key(..., mode="blank"), additivemode="copy", hold or cyclemode="instance"/G.cycle(...), end an exposure withG.end_exposure. Every visible layer needs a key at or beforeframe_start(Elijah). Cutout motion: key object transforms (FK). In-betweens: checkG.match(dA, dB), thenG.interpolate(ob, layer, frame, step=2, type, easing)(native, segment aroundframe) orG.inbetween(ob, layer, a, b, frames, spacing=[...] or ease=...)for the animator's own spacing,clip=Truewhen the pair is different. GATE:G.exposures(ob, layer)per layer,G.render_strip(path, frames)read left to right for timing and pops,G.onion(path, frame, before, after)for arcs and spacing. - 2.5D (optional). Outlines:
G.lineart(source, radius=width_m)(the modifier radius is the line width). Strokes on meshes:G.project_to_surface(pts, mesh)and discard strokes with missed or occluder-captured points (Pablo's failure modes). Relight:G.relight(ob, canvas, light_dir, shadow, light). Procedural strokes: Geometry Nodes Curves to Grease Pencil with Instances as Layers off and afill_idattribute, then apply to bake (procedures P7). Depth reorder without changing the image:G.push_depth(ob, delta). GATE: a moving render (strip or playblast) where the surface does not read as 3D (Weigl) and fills do not poke through (Pablo). - Deliver. Render frames or video with the scene camera; keep the .blend editable (no applied procedural stage the animator still needs). GATE: every object's verdict clean, strip and final frames looked at, list what was not verified.
Numbers
| Value | Meaning | Source |
|---|---|---|
radius = half line width (m) |
width px = 2 x radius / px_size | verified |
| px_size = ortho_scale / max(res_x, res_y) | ortho; perspective uses depth x 2 tan(fov/2) / res | verified |
| 0.01 m | point radius while the radius attribute is absent |
verified |
Line Art radius default 0.0025 m |
is the line WIDTH: points get radius/2 | verified |
| Elijah's Line Art thickness 60 (4.x) | about 0.006 m width in 5.2 | [inferred] |
use_lights True, World.color 0.05 |
new layers render flat colors at about 25 % value | verified |
set_uniform_thickness(thickness=t) |
sets radius t/2 | verified |
| ~60 parts, ~100 rig versions, 2 weeks rig for a lead | Ewilan's Quest cutout scale | Tom |
| 12 fps, 60 frames, ortho scale 8, Subdivision 2 to 3 on shape meshes | hybrid motion graphics | Elijah |
| Stabilizer 40 px / 0.9 bundled, Elijah uses ~60 on demand | only for long curves | Elijah, verified defaults |
| 1 light bounce | enough for stylized relighting | Weigl |
GP anti-aliasing aa_samples 8 |
scene.grease_pencil_settings | verified |
Quality gates
Measurable (per object, every stage):
for ob in gp_objects:
print(ob.name, G.verdict(G.report(ob))) # first key vs frame_start, lit-without-light,
# invisible strokes, stray micro-strokes, sub-pixel lines,
# fill/stroke alpha 0, empty masks; info: instances, unused materials
print(G.draw_order(gp_objects)) # front -> back vs the staging plan
print(G.match(dA, dB)) # before any interpolation
Visual (open every image): G.render_frame(path, f) at full resolution for line quality, trims, fill gaps and palette; G.render_strip(path, range(a, b, 2)) for timing, holds and pops; G.onion(path, f, before, after) for spacing and arcs (it includes object motion and modifiers, which the viewport onion skin ignores, Falk and Rik); for 2.5D a sequence, never a still. Score against references/critique.md.
Common mistakes
| Mistake | Looks like | Fix |
|---|---|---|
Layers left at use_lights=True with no lamps |
flat palette renders dark brown or gray | layer.use_lights=False, or raise World.color and add lamps |
| Fill expected from the material | shape outline only | stroke.fill_id=1, cyclic=True; holes share the id |
hide_stroke=True with fill_id=0 |
stroke vanishes | give it a fill id or show the stroke |
attributes.new("radius") on a drawing with strokes |
existing strokes disappear (radius 0) | fill defaults first (G.add_strokes does) |
| First key after frame_start | drawing pops in, empty frames at the start | key at frame_start (Elijah) |
| Interpolating different drawings | smeared, twisted in-betweens | G.match; redraw, resample, or clip (Tom) |
| Rigging drawings with armature weights | every redraw breaks deformation | object-level cutout, sculpt as deformer (Tom) |
| Stroke-level depth sorting | artist's layer order destroyed | sort groups (objects or mesh islands) (Weigl) |
| One big fill on a curved surface | fill pokes through the mesh | many small fills near the surface (Pablo) |
| Deleting stray points | stroke splits in two | G.dissolve keeps it continuous (Pablo) |
| Line Art radius set like a stroke radius | outline half the intended weight | Line Art radius = width = 2 x stroke radius |
| Judging 2.5D on a still | looks painterly, reads as 3D in motion | render the sequence (Weigl) |
| Calling draw brushes from a script | Invalid operator call, nothing drawn |
write strokes as data; see 5.2 notes |
Blender 5.2 notes
- Types: object
GREASEPENCIL, databpy.data.grease_pencils;layer.frames.new(n).drawing.add_strokes([...]); oldframe.strokes,point.co,pressure,strength,GPENCILandobject.grease_pencil_modifiersare gone. Modifiers are regular:ob.modifiers.new(n, 'GREASE_PENCIL_NOISE'), Line Art is'LINEART'. - Stroke and point helpers are Python wrappers: re-fetch after adding or removing strokes, never cache them.
grease_pencil.brush_strokeandsculpt_painthave no exec: called with a stroke list in a live session they log "Invalid operator call" and draw nothing (verified GUI); replayed mouse events viaevent_simulatealso drew nothing. Draw and sculpt strokes are data; headless edit operators (stroke_smooth,stroke_simplify,set_uniform_thickness,stroke_subdivide,outline,join_fills,set_curve_type) run in Edit mode throughG.edit_op. In a live sessionG.gui_trim(lasso Trim),G.gui_erase_boxandG.gui_reproject(type='SURFACE')work with a 3D-view override.interpolate_sequenceruns headless on the active object and layer, fills only the segment around the current frame and inserts BREAKDOWN keys; easing viatypeandeasing.frames.neworframes.copyonto an existing frame raises "Frame already exists";get_frame_at(n)returns the key exposed at n (None before the first key).layer.mask_layers.add(layer)is the 5.2 mask API; layer groupsgp.layer_groups.new,gp.layers.move_to_layer_group; layers can parent to an object or bone (layer.parent,parent_bone) and carry their own transform.- Line Art objects made by
object.grease_pencil_add(type='LINEART_OBJECT')have lit layers; objects hidden from render are ignored by Line Art;lineart_bake_strokesbakes the whole scene range. - GN Curves to Grease Pencil drops plain curves while Instances as Layers is on (default); Set Material alone no longer fills (5.1): store an INT
fill_idon the curve domain.
References
references/expert-notes.md: each expert's principles, judgment and disagreements with timestamps; load when making a style or pipeline decision.references/procedures.md: tested raw-bpy procedures (P1 to P8) and bx_gp recipes (cutout character, frame-by-frame, interpolation, 2.5D); load before writing GP code.references/critique.md: the rubric for judging line, fill, staging, timing and 2.5D renders; load at every review gate.references/sources.md: the five source videos, what each is best for, best timestamps.
Files (skills)
-
references
-
critique.md 11.7 KB
# Critique rubric: judging Grease Pencil output like a 2D lead Use at every gate. Each item says what the experts look at, how the agent checks it (code or render), and what failing looks like. Severity: **block** (fix before the next stage), **fix** (fix before delivery), **note** (mention, fix if cheap). Run the code checks first, then open the renders. ```python for ob in gp_objects: print(ob.name, G.verdict(G.report(ob))) G.render_frame("/abs/review/f.png", f) # full resolution G.render_strip("/abs/review/strip.png", range(a, b + 1, 2), cols=6, res_pct=30) G.onion("/abs/review/onion.png", f, before=(f - 2,), after=(f + 2,)) ``` ## 1. Structure and editability | Check | How | Fail | Severity | | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------- | | One object per physical element that moves or changes depth; layers inside by graphic role (Tom) | list objects and `[l.name for l in ob.data.layers]` | 200 layers in one object, or a part that must go behind another living on a layer | block | | Nothing blocks redrawing: no armature weights on drawings, procedural stages baked or kept optional (Tom, Weigl) | modifiers per object; `GREASE_PENCIL_ARMATURE` on a hand-drawn part | the animator cannot select and redraw a part | block | | Layer order: Lines above Shadow above Fills (Grant) | `report()["layers"]` | lines under fills, shadow on top of lines | block | | Materials as buckets: one per color, no duplicates, no unused (Grant) | `report()["info"]` unused materials; compare colors | three identical greens, recolor edits miss strokes | fix | | Keys cover the range: every visible layer keyed at or before frame_start (Elijah) | `verdict` "first key" | drawing pops in, empty first frames | block | | Instances are intended (editing one edits all) | `report()["info"]` instanced drawings | a "fix" on one frame silently changes a cycle | note | ## 2. Line quality | Check | How | Fail | Severity | | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------- | -------- | | Line width readable at output size | `verdict` sub-pixel lines; width px = 2 r / `G.px_size()` | hairlines, lines vanishing on downscale | block | | One line system: drawn lines match Line Art weight (Elijah); Line Art radius = width = 2 x stroke radius | compare radii; render crop | outline thin next to drawn details | fix | | Pressure taper on INK_PEN lines, constant on PEN lines, consistently per character | inspect radius profile, render | blunt sausage lines where taper was intended, or random widths | fix | | Ends: overlap then trim, no overhangs left, no gaps at joins (Grant) | full-res render zoomed on joins | line ends poking past a crossing, gaps where lines should meet | fix | | Smooth curves, no facets or wobble (Elijah's "no facets"; smooth_path or `edit_op("stroke_smooth")`) | render at 100 %; roughness of second differences | polygonal corners on round shapes | fix | | No stray micro-strokes (Grant's cleanup) | `verdict` stray micro-stroke | dots and slivers | fix | | Heavier lines where momentum peaks (Elijah, impact frames) | strip at the hit frame | uniform weight on an impact | note | ## 3. Fills and color | Check | How | Fail | Severity | | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------- | -------- | | Intended fills have `fill_id != 0`, holes share the outer id (Falk) | `drawing_stats()["fill_ids"]`; `verdict` invisible strokes | outline only, hole filled solid | block | | No gap between fill and line (Grant zooms for this after sculpting) | full-res render crop at edges | background showing between fill and line | fix | | Palette matches targets under Standard (Elijah) | sample pixels vs hex values; `view_transform == "Standard"` | shifted hues under AgX, dark colors | block | | Lighting intended: flat look has unlit layers; lit look has `World.color` ambient and lamps | `verdict` lit-without-light | a flat palette rendered at a quarter of its value | block | | Shadows read as designed shapes, masked to the fill, Multiply with lowered opacity (Grant, Pablo) | render; `layer.mask_layers`, blend mode | blobby soft 3D shading, shadow spilling outside the body | fix | | 2.5D fills do not poke through canvas meshes; many small fills rather than one big one (Pablo) | render from the shot camera and one off angle | fill slicing through the surface | fix | ## 4. Staging and depth | Check | How | Fail | Severity | | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- | -------- | | Front/behind order matches the plan | `G.draw_order(objs)` | arm drawn over the body when it should pass behind | block | | Multiplane discipline: elements parallel to the camera, camera axis fixed, unless the brief wants 3D moves (Tom) | object rotations vs camera; camera keys | flats seen edge-on, parallax that reveals cards | fix | | Everything the shot needs is inside the frame with margin; nothing drawn off screen (Tom's layout lesson) | render with passepartout or overscan; bbox vs camera frame | cut-off limbs, background plates too small for the camera move | block | | Depth reorders do not change the image (perspective: `push_depth`) | before/after render alpha diff | part visibly shrinks when sent behind | fix | ## 5. Timing, spacing and in-betweens | Check | How | Fail | Severity | | ---------------------------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------- | -------- | | Interpolated pairs are "sculpted" (Tom) | `G.match` before every interpolation | twisted or smeared in-betweens | block | | Different drawings clipped or redrawn, never forced (Tom's clip mode) | strip | mush between two unrelated poses | block | | Spacing follows intent (ease in/out, charted spacing), arcs on the onion | `G.onion`, `inbetween(spacing=...)` | even spacing everywhere, drawings popping off the arc | fix | | Object motion keyed with the drawings (native interpolation ignores it, Tom) | object F-curves exist where parts move | parts sliding under a static drawing | fix | | Repeated actions vary: blinks, hits, raindrops offset (Elijah) | key frame lists; strip | metronome timing | note | | Effects end with an empty key, not a hidden dot (Elijah's "better way") | `G.exposures` | leftover dots, effect lingering | fix | ## 6. 2.5D and hybrid | Check | How | Fail | Severity | | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------ | -------- | | Surface stays unreadable in motion (Weigl) | render a sequence, never a still | the 3D form reads through the strokes once it moves | block | | Projected strokes sit on their mesh; strokes with missed or occluder-captured points dropped (Pablo) | `project_to_surface` lists; distance to canvas | strokes floating off the silhouette or stuck to a hand | block | | Artist layer order preserved after sorting (Weigl) | layer stack per object; draw order of groups | strokes interleaving across parts | fix | | Detail density coherent with distance (Weigl's LOD) | strip of a dolly move | far objects crawling with strokes | note | | Hybrid 3D parts read as drawn (Pablo, Tom) | render | CG shading visible among drawn parts | fix | ## 7. Delivery evidence - Verdicts for every GP object, the strip, one full-resolution frame and, for animation, the onion at the busiest frame. - State what was not checked (for example LOD transitions, GUI-only operations, onion skin in the viewport). -
expert-notes.md 16.7 KB
# Expert notes: Grease Pencil (principles and judgment, by expert) Timestamps are `[mm:ss]` into the source video (see `sources.md`). [added] marks my own additions, [verified] a fact checked in Blender 5.2.1 by the tests under `tests/code/blender-grease-pencil/` or the distiller probes in `archive/tests/grease_pencil/`, [inferred] an unconfirmed reading. ## Tom Viguier (technical director, Andarta Pictures): Ewilan's Quest, a 2D series in GP (BCON24, legacy GP on 3.6) Production judgment at series scale (8 x 26 min). His tools target 3.6; the principles transfer, the code does not. - **Stay true to the 2D DNA.** Multiplane "like Disney's", not a 3D set: every 2D element parallel to the camera, the camera never shifts its axis, 3D mixed in only where it saves production time `[14:39] [15:12] [15:46]`. Drawing flat then spinning it in 3D "doesn't serve any purpose" `[14:06]`. "Sometimes keeping it simple is just the way to go" `[15:46]`. - **Layout owns staging.** Compositing kept finding missing background pieces, forgotten cuts for interaction, blurred-away backgrounds and animation drawn off screen; the fix was a strong layout department that validates camera, depth of field, background cuts and parallax before production `[16:31] [17:03]`. - **Background plates from the layout camera.** Project the camera frame onto each element's drawing plane, compute the plate resolution with a margin, link the painting back so one repaint updates every shot `[17:35] [18:08]`. Agent version [added]: `cam.data.view_frame(scene=sc)` corners scaled by `D / -corner.z` for a plane at distance D, transformed by `cam.matrix_world`, then `bx_gp.px_size(depth=D)` gives meters per pixel. - **The rig must never block redrawing.** "At any point you could just select an element, redraw it" `[22:01]`. Redrawing has a subtlety deformers lack and is sometimes just as fast `[21:29]`. Armatures used the 3D way mean weight painting every new drawing `[22:01] [22:34]`. - **Rig with objects.** One GP object per physical element, layers inside by graphic logic (line in front of color), object depth animates front/behind `[22:34] [23:41]`. A layer stack cannot animate depth order; 200 layers in one object is unusable `[23:06] [23:41]`. [verified] Between GP objects the nearer origin draws on top (default 2D stroke depth order). - **Cutout rig build.** About 60 parented elements for a lead, mostly FK in Object mode, an operator slides a part in depth to put it in front; a 2D IK on an armature still manipulated in Object mode with IK/FK switch; controls for line thickness; materials and palettes centralized in a separate file `[28:42] [29:16] [29:53]`. First rig took about 100 versions `[28:09]`; lead: 3 to 4 days design, about 2 weeks rigging; casual: 2 days and 3 days `[46:18] [46:53]`. - **Poses from the model sheet, validated by designers** before use `[29:53]`. Partial poses apply to the children of the selected object, composing from the top of the hierarchy down; master controllers browse pose collections with the mouse `[31:44] [32:20]`. Agent version [added]: store dicts of object transforms plus drawing frame numbers, apply to a subtree. - **In-betweens.** His tool interpolates GP frames and every F-curve of the object and its children; manual spacing input because animators know their spacing; "clip" picks the closest drawing when two are too different `[26:10] [26:44] [27:15]`. Native interpolation ignores the object's own animation `[26:10]`. - **Interpolation rule (Q&A).** "We do need to have the same amount of strokes and the same color of each stroke numbered in the order" `[45:04]`. Frame pairs are identical copies, completely different, or sculpted (same strokes and points), and only sculpted pairs interpolate `[45:39]`. Implemented as `bx_gp.match()` and `inbetween(clip=True)`. - **Frame instances.** He wanted one drawing reused in cycles with edits propagating; the Time Offset workaround blocked redrawing, so every frame stayed a unique copy `[33:30] [34:03]`. [verified] 5.2 has it: `frames.copy(a, b, instance_drawing=True)`, `drawing.user_count == 2`. - **Cross-object masks were a slow hack** (generate a layer in one object from another, boolean it, use as a mask), used only for cast shadows and reflections `[34:18] to [35:58]`. 5.2 still masks only within one object [inferred]: keep parts that must mask each other as layers of one object. - **Hybrid 3D where it pays:** reusable spider legs from many angles, horses cut into layers with holdout planes and reprojected so puppet parts interleave `[36:21] to [37:30]`. Turnarounds that are too fluid start "mimicking 3D, which is not the point at all" `[32:57]`. Smears are just drawings `[44:27]`. ## Grant Abbitt (educator, gamedev.tv): GP drawing for absolute beginners (4.4, GP v3) - **The canvas is the camera**; when the view drifts, snap back to camera view `[01:26] [03:41] [03:52]`. - **Materials are paint buckets.** "It's really important to understand that these are kind of like paint buckets" `[12:08]`: one edit recolors every stroke; delete unused materials to keep the palette clean `[12:39]`. - **Know the active layer and lock the rest.** "It is very important to know which layer you're drawing on" `[11:01]`; erasers and sculpt affect every unlocked layer `[09:20] [19:14] [19:46]`. - **Overlap then trim.** "A lot of time it's very useful when you're drawing to purposely overlap and then you can go in and cut those" `[10:29]`. [verified GUI] `bx_gp.gui_trim` cut an overshoot exactly at the crossing. - **Brush identity.** Pencil: pressure on strength and radius; Ink Pen: pressure on size only, "that nice cartoony look" `[02:36] [04:55]`. [verified] bundled assets: Pen no pressure, Ink Pen radius only, Pencil both. - **Shadow layer recipe.** Separate layer above Fills and below Lines `[15:25] [15:58]`, painted first in a contrasting temp color (blue) so it is visible over black lines `[18:08]`, masked by the Fills layer `[18:40]`, Multiply with lowered opacity so the exact color matters less: "the multiply is a bit nicer because it just darkens" `[20:18] [20:38]`. Reshape with sculpt Grab, others locked `[20:38]`. [verified by render] - **Fix curves with Grab**, the most useful sculpt brush; Smooth is "a little bit awkward" `[08:48] [09:20]`. Headless equivalent: `bx_gp.grab`. - **Erasing fills:** Point eraser beats Dissolve on fills; check leftovers in Edit mode `[09:58] [19:14]`. - **Judging:** toggle layer visibility to see what lives where `[06:32]`, zoom for gaps between fill and line after sculpting `[21:11]`, check framing by rendering `[24:26]`. - **Outdated in 5.2:** his fill workflow (fill-only material, Fill tool double tap, holdout "Eraser Fill" material for holes) `[11:34] to [15:00]` is replaced by per-stroke `fill_id`, real holes and the Delaunay fill solver (5.1, 5.2). ## Falk David, Rik Schutte, Pablo Fournier (Blender developers and Studio animators): GP3 updates and showcase (BCON24, 4.3) ### Falk David (led the GP3 rewrite) - "All of Grease Pencil is now attributes" on point and curve domains, layers can carry custom attributes, which is what makes Geometry Nodes possible `[07:20] [07:53]`. - **Why fill left the material:** "during rendering we don't actually know if a stroke is going to be filled or not because materials can for example be overridden at the object level" `[44:29]`; stroke vs fill belongs to the geometry or the draw tool `[09:34] [10:08]`. Shipped in 5.1. - **Holdout is a render trick** that also hides what is behind; real holes are the fix `[48:28] [49:02]`. Fill tools were "one of the weakest" `[09:34]`. - **Line Art's future is nodes:** "the answer is nodes" `[50:05]`. - Onion skin is object-relative and skips modifiers and texturing `[42:52] [43:24]`. `bx_gp.onion` renders ghosts with both. ### Rik Schutte (Studio animator) - **Block in 3D first**, then draw over it for perspective, timing and volume `[14:41] [15:14]`. - A pixel-art hybrid was only partially successful because GP has its own renderer `[15:47] [16:53]`. Smoke as a simple line with a modifier stack; sets are "smoke and mirrors" that fall apart off camera `[20:30] [22:13]`. - On unfinished software: reorder tasks and keep a triaged bug log with pictures `[21:41] [24:29]`. - Project Gold: match the painted stroke's texture, transparency and color to the render first, then animate splashes frame by frame, flipping frames because onion skin ignored texturing `[26:48] [27:21] [27:53]`. ### Pablo Fournier (Studio animator, NPR cowboy) - **Kill the blobby 3D shadows:** create all materials first, a light and a shadow material per part; redraw shadows as blocky designed shapes on top of what the light gives `[32:25] [33:00] [33:33]`. - **Duplicate the stroke object per body part** so each part stays trackable `[33:00]`; strokes are reusable across similar poses with touch-ups `[34:39]`. - **Sticky strokes fail two ways:** points drawn over an occluder stick to the occluder; points off the mesh "go to oblivion" `[35:44] [36:16]`. [verified] `bx_gp.project_to_surface` returns both lists (`missed`, `captured`). - **"The solution is dissolving the points, not delete them"** `[36:48]`: deleting breaks the stroke. `bx_gp.dissolve` keeps continuity. - **Fills are flat:** a big fill on a curved surface pokes through; pushing offsets cascades until "everything was like 5 km away"; use many small fills `[37:23] [37:57] [38:31]`. - Line Art plus noise and envelope modifiers; where it misbehaves, draw over it `[39:47] [40:20]`. ## Michael Weigl (TD, Filmakademie Baden-Wuerttemberg): bridging GP and Geometry Nodes for 2.5D (BCON25) - **Readability and artistic agency** are the two goals of 2.5D motion graphics `[05:55] [06:29]`. - **Motion beats surface:** "we can polish an image to be perfect in like a still, but the moment we start moving the 3D object we suddenly don't read the strokes and shapes anymore but the motion" `[07:03]`. Deconstruct surface and motion. - **Work with the medium:** forcing 2D toward 3D or the reverse makes workload grow "exponentially" `[05:04] [07:35] [08:06]`. - **Sorting:** raw 3D depth makes strokes on one surface intersect, per-stroke z-sort destroys the artist's layers; group strokes into parts that cannot intersect (mesh islands), z-sort groups, never sort inside a group `[12:27] [13:00] [13:33] [14:02]`. Agent substitutes: separate GP objects per group (object depth decides, verified), `grease_pencil.stroke_depth_order = '3D'` when intersections are acceptable, `drawing.reorder_strokes` inside a drawing. - **Canvases carry drawings like textures:** "just imagine that the grease pencil you draw are just like textures and they come with always meshes" `[24:21]`; stages can always add canvases and drawings `[16:10] [16:43]`. - **LOD by camera distance** keeps drawn detail density coherent, "very important for stylized rendering" `[19:10]`. [verify] not built in bx_gp. - **Procedural output stays editable:** bake to layers, edit any stroke, "not in a black box, not pixel based" `[19:42] [21:39]`. [verified] applying a GN modifier bakes into a layer named `Layer`. - **Lighting:** "it's stylized rendering, you don't need more than one bounce" `[28:13]`; shading approximated once per stroke `[35:09]`. `bx_gp.relight` is a one-pass per-point diffuse version [verified by render]. - Detail only where displayed: "you only need to add as much detail and work into anything as you want to display" `[27:06]`. Natural phenomena (water) can be fully procedural; guide curves would fight constant change `[34:36] [35:09]`. ## Elijah Sheffield (3D motion designer): GP quick start, hybrid motion graphics (2025, GP v3) - **"Lazy artist":** meshes as shape layers, Line Art outlines, drawn details and effects only `[00:00] [06:17] [06:50]`. - **Predictable color:** EEVEE, Standard instead of AgX, flat or toon shader, white world: "we just want really predictable colors to come out of our viewport" `[01:56] [02:29] [05:45] [07:55]`. - **Scene numbers:** 1080 x 1920, 12 fps (hand-crafted jitter, fewer drawings), 60 frames, ortho camera scale 8, passepartout 1 `[02:29] [03:01] [05:45] [43:05]`. - **No facets:** Subdivision level 2, 3 where facets still show; edge-only meshes render only through Line Art `[09:00] [11:42] [12:15] [14:26]`. - **Match the drawn line to Line Art:** Pen brush (constant width) and adjust size by eye `[22:34] [25:17]`. [verified] Line Art modifier `radius` equals line width, so a drawn stroke of radius r matches Line Art radius 2r. - **Draw on surfaces:** placement Surface, not Origin, or strokes land behind the mesh `[22:34] [23:08]`. Headless: `project_to_surface`. - **First key lands on the current frame:** scrub back and the drawing vanishes; move it to the start `[24:11] [24:43]`. `bx_gp.report` flags it. - **Stabilizer on demand** (hold Shift), not always on `[25:50] [26:23]`. - **Additive vs replacement keys:** additive for features that must stay put (face during a blink), replacement for effects `[34:23] [35:28] [47:01]`. **Blink** at frame 6 with onion skin, open key duplicated after, repeated about every second `[36:01] to [38:45]`. - **Timing variety:** duplicate and offset repeated hits so they look random `[43:05] to [45:53]`; heavier lines at impact where momentum peaks `[46:28]`. Ending an effect: he keys an off-canvas dot, "there's definitely a better way" `[47:33]`: an empty key (`bx_gp.end_exposure`) [verified]. - **Strokes as blob-brush shapes:** "think of the strokes not so much like a pencil stroke ... but more like a blob brush stroke in Illustrator" `[23:40]`. "Draw from the shoulder. Don't draw from the elbow." `[31:21]`. - Parent drawn details to the meshes they decorate `[49:16]`. ## Where experts disagree, and the deciding condition | Question | Positions | Decide by | | ------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | Multiplane flats or 3D canvases | Tom: flats parallel to a fixed-axis camera. Weigl, Pablo: draw on real meshes | 2D cinematic language and TV budget: multiplane. Relighting, painterly NPR, big camera moves: canvases, with a TD per style (Weigl `[29:54]`) | | How characters move | Tom: object cutout + redraw + sculpt. Pablo: strokes stuck to a deforming mesh. Weigl: procedural | Artists must redraw freely: no armature weights on drawings. Character is a 3D rig: stick strokes to the mesh | | Additive or replacement key | Elijah uses both | Does the new drawing keep most of the previous one | | Brush | Pen constant (Elijah) vs Ink Pen pressure (Grant) | Match the existing line system (Line Art means Pen) | | Line Art or drawn outline | Line Art + noise/envelope (Rik, Pablo, Elijah), overdraw its errors (Pablo), nodes (Falk) | Clean mesh shapes: procedural. Designed outlines: drawn | | Removing points | Point eraser on fills (Grant), Dissolve in Edit mode (Pablo) | Keep the stroke continuous either way | | Procedural vs artist input | Weigl's generators vs Tom's refusal of anything blocking redraw | Natural phenomena procedural, characters drawable | ## Outdated content vs 5.2 (do not follow) - Material-driven fill and the holdout eraser fill (Grant, Elijah): use `fill_id`, holes by shared id, `hide_stroke` (5.1); `show_stroke`/`show_fill` raise a deprecation warning [verified]. - Legacy GP (Tom): `GPENCIL`, `frame.strokes`, `grease_pencil_modifiers` gone; his add-ons need 3.6 `[48:37]`. - Line Art "thickness" (Elijah's 60): property is `radius`, in meters, meaning line width [verified]. - 4.3 roadmap items (Falk) shipped: Stroke/Fill/Both draw option and holes (5.1), Pen tool (5.0), Delaunay fill default and draw-tool curve type (5.2). EEVEE rendering of GP with node materials not in 5.2 [inferred]. - Onion skin gained "active object only" (4.5); world-space onion skin not in release notes through 5.2 [inferred]. -
procedures.md 19.2 KB
# Procedures: Grease Pencil v3 in Blender 5.2 (tested) Every block below ran on Blender 5.2.1 LTS (`blender --background --factory-startup`) unless marked GUI only. Raw-bpy blocks P1 to P8 are copied verbatim from `tests/code/blender-grease-pencil/test_procedures.py` (between `# >>> Pn` and `# <<< Pn`), which asserts their results. bx_gp recipes are exercised by `test_bx_gp_2d.py`, `test_bx_gp_25d.py` and, in a live session, `test_gui_draw.py`. Outputs of the last runs: `archive/tests/grease_pencil_skill/`. Conventions: drawing plane XZ, camera at -Y looking +Y, meters, `radius` = half line width, material colors scene-linear (bx_gp accepts sRGB hex). ## P1. Data model from scratch: object, layers, materials, strokes, fill with a hole Verified on 5.2.1: `test_procedures.py` P1 (and pixel-checked in P2: hole shows the background, ring shows linear 0.6 as sRGB 0.80). ```python import bpy, math sc = bpy.context.scene gp = bpy.data.grease_pencils.new("Doodle") # data: bpy.types.GreasePencil (v3) ob = bpy.data.objects.new("Doodle", gp) # ob.type == 'GREASEPENCIL' sc.collection.objects.link(ob) def gp_material(name, stroke=(0, 0, 0, 1), fill=(1, 1, 1, 1)): m = bpy.data.materials.get(name) or bpy.data.materials.new(name) if not m.is_grease_pencil: bpy.data.materials.create_gpencil_data(m) # turns it into a GP material m.grease_pencil.color = stroke # scene-linear RGBA m.grease_pencil.fill_color = fill return m gp.materials.append(gp_material("Ink")) # slot 0 gp.materials.append(gp_material("Skin", fill=(1.0, 0.6, 0.5, 1))) # slot 1 fills = gp.layers.new("Fills") # first created = bottom of the stack lines = gp.layers.new("Lines") for L in gp.layers: L.use_lights = False # default True: flat colors render dark ring = lambda r, n=48: [(r * math.cos(2 * math.pi * i / n), 0.0, r * math.sin(2 * math.pi * i / n)) for i in range(n)] d = fills.frames.new(1).drawing # frame -> drawing (curves + attributes) d.add_strokes([48, 48]) # two curves in one call for s, r in zip(d.strokes, (1.0, 0.4)): for p, co in zip(s.points, ring(r)): p.position = co s.cyclic = True s.material_index = 1 s.fill_id = 1 # != 0 fills; equal ids fill together (even-odd): the 0.4 ring is a hole s.hide_stroke = True # fill only, the outline lives on the Lines layer d = lines.frames.new(1).drawing d.add_strokes([48]) s = d.strokes[0] # helper objects: re-fetch after any add/remove, never cache for p, co in zip(s.points, ring(1.0)): p.position = co p.radius = 0.03 # HALF the line width, meters p.opacity = 1.0 s.cyclic = True d.tag_positions_changed() ``` Notes: attributes appear lazily (`position`, `curve_type` at creation; `radius`, `opacity`, `cyclic`, `fill_id` when first written). Never call `drawing.attributes.new("radius", ...)` on a drawing that already has strokes without filling the old points: they get radius 0 and vanish (use `bx_gp.add_strokes`, which fills defaults first). Default radius while the attribute is absent: 0.01. ## P2. Stage, lighting and render settings Verified on 5.2.1: `test_procedures.py` P2 (pixel samples asserted). ```python cd = bpy.data.cameras.new("Cam") cd.type = 'ORTHO' cd.ortho_scale = 4.0 # frame width in meters (larger image side) cam = bpy.data.objects.new("Cam", cd) sc.collection.objects.link(cam) cam.location = (0, -10, 0) cam.rotation_euler = (math.radians(90), 0, 0) # looks +Y at the XZ drawing plane sc.camera = cam sc.render.engine = 'BLENDER_EEVEE' # GP renders in EEVEE, Cycles and Workbench sc.view_settings.view_transform = 'Standard' # AgX shifts palette colors sc.world.node_tree.nodes["Background"].inputs["Color"].default_value = (1, 1, 1, 1) sc.world.color = (0.05, 0.05, 0.05) # ambient for LIT GP layers (not the node tree) sc.render.resolution_x = sc.render.resolution_y = 400 sc.render.film_transparent = False # True: alpha background for compositing sc.grease_pencil_settings.aa_samples = 8 # GP anti-aliasing (default 8) sc.render.filepath = "//doodle.png" ``` Lighting facts [verified]: with `use_lights=True`, a layer is lit by lamps plus an ambient equal to `World.color` (the world's viewport color), not the Background node; the default 0.05 renders a flat palette at about 25 % value. A sun on a flat drawing only scales value; a point light gives a gradient. EEVEE, Cycles and Workbench render unlit GP with identical colors. ## P3. Timing: blank, additive, instanced and empty keys, native interpolation Verified on 5.2.1: `test_procedures.py` P3 (keys `[1, 3, 5, 7, 9, 17, 21]`, 3 to 7 BREAKDOWN, frame 17 shares frame 1's drawing, 21 empty). ```python L = gp.layers["Lines"] f1 = L.frames[0].frame_number # 1 L.frames.copy(f1, 9) # additive start: an independent copy d9 = L.get_frame_at(9).drawing for p in d9.strokes[0].points: # 'sculpt' the copy: same strokes and points x, y, z = p.position p.position = (x * 1.3, y, z * 0.8) d9.tag_positions_changed() L.frames.copy(f1, 17, instance_drawing=True) # hold / cycle: shares frame 1's drawing L.frames.new(21) # empty key: the layer shows nothing from 21 bpy.context.view_layer.objects.active = ob gp.layers.active = L sc.frame_set(1) bpy.ops.grease_pencil.interpolate_sequence( # runs headless, only the segment 1 -> 9 step=2, layers='ACTIVE', type='SINE', easing='EASE_IN_OUT', exclude_breakdowns=True) keys = [(f.frame_number, f.keyframe_type, len(f.drawing.strokes), f.drawing.user_count) for f in sorted(L.frames, key=lambda f: f.frame_number)] ``` Notes: `interpolate_sequence` fills only the segment around the current frame (verified with keys 1, 9, 17: from frame 1 only 3, 5, 7 were made). `frames.new` or `frames.copy` onto an existing frame raises. `layer.get_frame_at(n)` is the key exposed at n. Removing the source of an instance keeps the drawing alive in the instance. ## P4. Masked Multiply shadow layer (Grant Abbitt) and layer groups Verified on 5.2.1: `test_procedures.py` P4; the mask clipping was checked by render in `test_bx_gp_2d.py` (`frame_01.png`: the shadow ellipse drawn past the body is clipped to it). ```python shadow = gp.layers.new("Shadow") # new layers go on top ... gp.layers.move(shadow, 'DOWN') # ... so move it between Fills and Lines shadow.use_lights = False shadow.blend_mode = 'MULTIPLY' # REGULAR HARDLIGHT ADD SUBTRACT MULTIPLY DIVIDE shadow.opacity = 0.5 shadow.use_masks = True shadow.mask_layers.add(gp.layers["Fills"]) # 5.2 API: only visible where Fills has paint grp = gp.layer_groups.new("Body") # groups: hide / lock / organize parts for name in ("Fills", "Shadow", "Lines"): gp.layers.move_to_layer_group(gp.layers[name], grp) stack = [l.name for l in gp.layers] # bottom -> top ``` ## P5. Modifiers are regular object modifiers Verified on 5.2.1: `test_procedures.py` P5. Other types: `GREASE_PENCIL_` + `COLOR TINT OPACITY TIME TEXTURE ARRAY LENGTH MIRROR MULTIPLY SIMPLIFY SUBDIV ENVELOPE OUTLINE HOOK OFFSET SMOOTH LATTICE DASH ARMATURE SHRINKWRAP VERTEX_WEIGHT_PROXIMITY VERTEX_WEIGHT_ANGLE`, plus `LINEART` [enum verified]. ```python noise = ob.modifiers.new("Wobble", 'GREASE_PENCIL_NOISE') thick = ob.modifiers.new("Weight", 'GREASE_PENCIL_THICKNESS') thick.thickness_factor = 1.5 build = ob.modifiers.new("Reveal", 'GREASE_PENCIL_BUILD') # write-on over time mods = [(m.name, m.type) for m in ob.modifiers] for m in list(ob.modifiers): ob.modifiers.remove(m) ``` ## P6. Line Art object from the add operator, baked to strokes Verified on 5.2.1: `test_procedures.py` P6. ```python bpy.ops.mesh.primitive_uv_sphere_add(radius=1.0) src = bpy.context.active_object # active object = Line Art source bpy.ops.object.grease_pencil_add(type='LINEART_OBJECT') la_ob = bpy.context.active_object la = next(m for m in la_ob.modifiers if m.type == 'LINEART') la.radius = 0.01 # = line WIDTH in meters (points get radius/2) sc.frame_start = sc.frame_end = 1 # bake covers the whole scene range bpy.ops.object.lineart_bake_strokes() # modifier output -> editable keyframes baked = {l.name: [f.frame_number for f in l.frames] for l in la_ob.data.layers} ``` Notes [verified]: the generated points get radius = modifier radius / 2, so the modifier radius is the line width (to match a drawn stroke of radius r use 2r). Objects hidden from render are ignored. Layers made by this operator are lit (`use_lights=True`): set False for colored flat lines. The bake covers the whole scene frame range. After baking, the modifier is still listed. ## P7. Procedural strokes with a fill from Geometry Nodes, baked to editable layers (Weigl's bridge) Verified on 5.2.1: `test_procedures.py` P7 (one layer `Layer`, `fill_id` 1, radius 0.03 carried over); render of the same graph in `archive/tests/grease_pencil/probe4c_render.png` (distiller). ```python gp = bpy.data.grease_pencils.new("Proc") ob = bpy.data.objects.new("Proc", gp) sc.collection.objects.link(ob) mat = bpy.data.materials.new("ProcFill") bpy.data.materials.create_gpencil_data(mat) mat.grease_pencil.fill_color = (1.0, 0.8, 0.05, 1) gp.materials.append(mat) ng = bpy.data.node_groups.new("CurvesToGP", 'GeometryNodeTree') ng.interface.new_socket("Geometry", in_out='INPUT', socket_type='NodeSocketGeometry') ng.interface.new_socket("Geometry", in_out='OUTPUT', socket_type='NodeSocketGeometry') N, Lk = ng.nodes, ng.links gi, go = N.new('NodeGroupInput'), N.new('NodeGroupOutput') circ = N.new('GeometryNodeCurvePrimitiveCircle') circ.inputs['Radius'].default_value = 1.5 rad = N.new('GeometryNodeSetCurveRadius') rad.inputs['Radius'].default_value = 0.03 # carried over as point radius xf = N.new('GeometryNodeTransform') xf.inputs['Rotation'].default_value = (math.radians(90), 0, 0) # XY curve -> XZ drawing plane c2g = N.new('GeometryNodeCurvesToGreasePencil') c2g.inputs['Instances as Layers'].default_value = False # True (default) drops plain curves setm = N.new('GeometryNodeSetMaterial') setm.inputs['Material'].default_value = mat fid = N.new('GeometryNodeStoreNamedAttribute') fid.data_type, fid.domain = 'INT', 'CURVE' fid.inputs['Name'].default_value = 'fill_id' # 5.1+: material alone no longer fills fid.inputs['Value'].default_value = 1 Lk.new(circ.outputs['Curve'], rad.inputs['Curve']) Lk.new(rad.outputs['Curve'], xf.inputs['Geometry']) Lk.new(xf.outputs['Geometry'], c2g.inputs['Curves']) Lk.new(c2g.outputs[0], setm.inputs['Geometry']) Lk.new(setm.outputs['Geometry'], fid.inputs['Geometry']) Lk.new(fid.outputs['Geometry'], go.inputs[0]) ob.modifiers.new("GN", 'NODES').node_group = ng bpy.context.view_layer.objects.active = ob bpy.ops.object.modifier_apply(modifier="GN") # bake to editable layers (named 'Layer') for L in gp.layers: L.use_lights = False ``` Other GP nodes [verified ids]: `GeometryNodeGreasePencilToCurves` (Layers as Instances), `GeometryNodeMergeLayers`, `GeometryNodeSetGreasePencilColor` (mode STROKE or FILL), `GeometryNodeSetGreasePencilDepth`, `GeometryNodeSetGreasePencilSoftness`. ## P8. Layer parented to an object, layer transforms Verified on 5.2.1: `test_procedures.py` P8 (properties set and read back; the evaluated effect on points was not asserted). ```python hand = bpy.data.objects.new("HandCtrl", None) # an empty, a bone owner, any object sc.collection.objects.link(hand) lay = gp.layers[0] lay.parent = hand # the layer follows the empty # lay.parent = armature_ob; lay.parent_bone = "hand.R" # or a bone hand.location = (1.0, 0, 0) bpy.context.view_layer.update() lay.translation = (0.0, 0.0, 0.5) # layers also carry their own transform ``` Use: several parts inside ONE GP object that must still move separately (they can then mask each other, which separate objects cannot). Tom Viguier's cutout uses separate objects instead because depth order must animate. --- ## bx_gp recipes R1 runs as is: `test_procedures.py` extracts it from this file and executes it. R2 to R7 are excerpts of the named test scripts, where the full context (objects they act on) lives. ### R1. Part-based cutout character (Tom Viguier's architecture) Verified on 5.2.1: this block is executed by `test_procedures.py` (R1); the full character with face, blink, mouth, donut and animation is `test_bx_gp_2d.py` (renders `2d/frame_01.png`, `2d/strip_1_to_23.png`). ```python import math, sys sys.path.append("<skill>/scripts") # scenario-blender-grease-pencil/scripts import bx_gp as G cam = G.scene_2d(res=(1080, 1080), fps=12, frame_range=(1, 24), ortho_scale=6.0) body = G.new_object("Body", layers=("Fills", "Shadow", "Lines")) # bottom -> top, unlit ink, mint = G.material(body, "Ink", stroke="#1d1b24"), G.material(body, "Mint", fill="#8fd3c8") shade = G.material(body, "Shade", fill="#4a5a9c") outline = G.smooth_path(G.xz([(0, 1.15), (0.85, 0.9), (1.2, 0), (1.1, -1), (0, -1.5), (-1.1, -1), (-1.2, 0), (-0.85, 0.9)]), samples=10, closed=True) G.add_shape(G.key(body, "Fills", 1), outline, material=mint) G.add_shape(G.key(body, "Shadow", 1), G.ellipse(1.3, 1.9, center=(1.05, 0, -0.55), rot=0.25), material=shade) sh = body.data.layers["Shadow"] sh.blend_mode, sh.opacity, sh.use_masks = "MULTIPLY", 0.45, True sh.mask_layers.add(body.data.layers["Fills"]) # drawn past the edge, clipped by the fill G.add_stroke(G.key(body, "Lines", 1), outline, radius=0.028, material=ink, cyclic=True) arm = G.new_object("ArmR", layers=("Fills", "Lines"), location=(1.05, -0.1, -0.15)) # origin = shoulder arm.parent = body # y=-0.1: in front of the body feet = G.new_object("Feet", layers=("Fills", "Lines"), location=(-0.35, 0.05, -1.5)) # y=+0.05: behind feet.parent = body for part in (arm, feet): # each part draws in its own object G.material(part, "Ink"); G.material(part, "Mint") G.add_shape(G.key(part, "Fills", 1), G.ellipse(0.35, 0.15, center=(0.35, 0, 0)), material=1) G.add_stroke(G.key(part, "Lines", 1), G.ellipse(0.35, 0.15, center=(0.35, 0, 0)), radius=0.022, cyclic=True) # cutout motion = FK on object transforms, drawings stay editable for f, deg in ((1, 0), (9, -38), (24, 0)): arm.rotation_euler = (0, math.radians(deg), 0); arm.keyframe_insert("rotation_euler", frame=f) print(G.draw_order([body, arm, feet])) # front -> back ``` Front/behind changes: key the object's location along the camera axis, or `G.push_depth(ob, delta)` which also compensates scale under a perspective camera (verified: silhouette unchanged, order flipped, `test_bx_gp_25d.py`). ### R2. Frame-by-frame with holds, instances, cycles and a blink Verified on 5.2.1: `test_bx_gp_2d.py` (blink on Face/Fills at 15, open again at 17 as an instance; FX cycle). ```python d_open = G.key(face, "Fills", 1) # draw open eyes d_shut = G.key(face, "Fills", 15) # replacement drawing: closed eyes G.key(face, "Fills", 17, mode="instance", source=1) # open again, same drawing G.cycle(fx, "FX", keys=[1, 3, 5], start=1, end=17, step=2) # loop on twos as instances G.end_exposure(fx, "FX", 19) # blank key ends the effect (not an off-canvas dot) print(G.exposures(face, "Fills")) # [(frame, type, n_strokes, user_count)] ``` ### R3. In-betweens with Tom's rule and the animator's spacing Verified on 5.2.1: `test_bx_gp_2d.py` (native 1 -> 9 on twos; eased 9 -> 17 gives t = 0.156, 0.5, 0.844; the 17 -> 21 pair is refused, then clipped). ```python print(G.match(dA, dB)) # copy / sculpted / different + reason G.interpolate(face, "Mouth", 1, step=2) # native, BREAKDOWN keys 3, 5, 7 G.inbetween(face, "Mouth", 9, 17, frames=(11, 13, 15), ease="EASE_IN_OUT") G.inbetween(face, "Mouth", 9, 17, frames=(11, 13, 15), spacing=[0.1, 0.35, 0.8]) # charted spacing G.inbetween(face, "Mouth", 17, 21, frames=(19,), clip=True) # different drawings: nearest key instead ``` To make two drawings interpolable, draw both from the same control structure and `G.resample(points, n)` each stroke to the same count, in the same stroke order and materials. ### R4. Headless sculpt and eraser substitutes Verified on 5.2.1: `test_bx_gp_25d.py` (smooth cut roughness 0.072 to 0.001; `set_uniform_thickness(thickness=0.03)` gave radius 0.015; grab moved 23 points; dissolve 61 -> 58 points, still one stroke). ```python G.edit_op(ob, "stroke_smooth", iterations=10, factor=1.0) G.edit_op(ob, "set_uniform_thickness", thickness=0.03) # thickness = width: radius 0.015 G.grab(d, center=(0, 0, 0), radius=0.4, offset=(0, 0, 0.3)) # sculpt Grab with falloff G.dissolve(d, stroke_index=0, point_indices=[20, 21, 22]) # keeps the stroke continuous ``` ### R5. Live session only: trim, box erase, reproject GUI only, not run headless. Verified in a live 5.2.1 GUI by `test_gui_draw.py`: Trim cut the overshoot at the crossing (bbox x max 1.0 -> 0.6), box erase removed 7 points, reproject SURFACE put a circle on a sphere (y -0.79 to -0.80, expected -0.80). ```python G.gui_trim(ob, lasso=[(0.7, 0, 3.15), (1.15, 0, 3.15), (1.15, 0, 2.85), (0.7, 0, 2.85)]) G.gui_erase_box(ob, (0.5, 0, 2.0), (0.7, 0, 2.3)) G.gui_reproject(ob, "SURFACE", frame_points=[(-1.2, 0, -4.2), (1.2, 0, -1.8)]) ``` Not possible (verified GUI): `grease_pencil.brush_stroke` / `sculpt_paint` with a stroke list log "Invalid operator call" (no exec) and draw nothing; replayed mouse events through `Window.event_simulate` (Blender launched with `--enable-event-simulate`) drew nothing either, batched or one event per 30 ms. ### R6. 2.5D: Line Art in pixels, strokes on a mesh, relight, depth Verified on 5.2.1: `test_bx_gp_25d.py` (render `25d/sheet_25d.png`; 134 of 260 hatch strokes kept, 703 missed points and 28 occluder-captured points rejected; projected points 1.099 to 1.109 from the center of a 1.1 sphere). ```python px = G.px_size() la = G.lineart(blob, radius=5 * px) # 5 px wide outline hits, missed, captured = G.project_to_surface(flat_pts, canvas, offset=0.01) if not missed and not captured: # Pablo: oblivion and occluder capture G.add_stroke(d, hits, radius=0.012, pressure=G.taper(len(hits))) G.relight(hatch, canvas, light_dir=(-0.6, -0.5, 0.6), shadow="#27305e", light="#ffd166") G.push_depth(disc, 1.0, persp_cam) # further back, same image ``` ### R7. Review renders Verified on 5.2.1: `test_bx_gp_2d.py` (`2d/strip_1_to_23.png`, `2d/onion_f5.png`, `2d/lighting_sheet.png`). ```python G.render_frame("/abs/f001.png", 1) # full res: line quality, gaps, palette G.render_strip("/abs/strip.png", range(1, 24, 2), cols=4, res_pct=30) # timing, pops G.onion("/abs/onion.png", 5, before=(1, 3), after=(7, 9)) # arcs and spacing, with object motion print(G.verdict(G.report(ob))) ``` -
sources.md 6.1 KB
# Sources: Grease Pencil Five videos, distilled into `notes/grease-pencil/` (one note per video plus `_digest_grease_pencil.md`). No contact-sheet frames were extracted for these talks; API facts were re-verified in Blender 5.2.1 by the distiller probes (`archive/tests/grease_pencil/`) and this skill's tests (`tests/code/blender-grease-pencil/`). | Video | Expert and credential | Version on screen | Best for | Best timestamps | | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Ewilan's Quest: creating a 2D series with Blender Grease Pencil](https://www.youtube.com/watch?v=41vUGbjHs2M) (BCON24, 50:44, 2024-10-24) | Tom Viguier, technical director at Andarta Pictures, former 2D animator, rigging head, animation lead and director | 3.6, legacy GP | Production judgment: multiplane staging, layout first, cutout rigs built from objects, redraw-ability, interpolation rules, frame instancing | multiplane `[14:39]` to `[15:46]`; layout `[16:31]` to `[18:41]`; rig vs redraw `[21:29]` to `[23:41]`; in-betweening `[26:10]` to `[27:48]`; rig build and poses `[28:09]` to `[32:57]`; frame instances `[33:30]`; masks `[34:18]` to `[35:58]`; hybrid 3D `[36:21]`; interpolation rule Q&A `[45:04]` | | [Grease Pencil Drawing for Absolute Beginners in Blender 4.4](https://www.youtube.com/watch?v=fSqWEoHBeeI) (26:21, 2025-05-18) | Grant Abbitt, long-running Blender educator, gamedev.tv Blender and GP courses | 4.4, GP v3 | Layers vs materials ("paint buckets"), lock discipline, overlap-and-trim, the masked Multiply shadow layer; his fill workflow is obsolete (useful as a warning) | brushes `[02:36]` to `[05:28]`; layers `[05:59]` to `[06:32]`; sculpt Grab and Trim `[08:48]` to `[10:29]`; materials `[12:08]` to `[14:28]`; shadow layer, mask, Multiply `[15:25]` to `[20:38]`; lock layers `[19:14]` | | [Grease Pencil 3: development updates and showcase](https://www.youtube.com/watch?v=rqkgqvW-VpM) (BCON24, 51:05, 2024-10-25) | Falk David (GP module developer, led the GP3 rewrite), Rik Schutte and Pablo Fournier (Blender Studio animators) | 4.3 | Why the data model is attributes and why fill moved to geometry; 3D blocking before drawing; sticky-stroke NPR and its failure modes; onion-skin blind spots | attributes `[07:20]`; fill roadmap `[09:34]`; 3D blocking `[14:41]`; Gold post pass `[24:29]` to `[28:26]`; Pablo's NPR workflow `[29:03]` to `[40:20]` (dissolve not delete `[36:48]`, flat fills `[37:23]`); onion skin `[42:52]`; fill vs material `[44:29]`; holes `[48:28]`; Line Art and nodes `[50:05]` | | [Bridging Grease Pencil and Geometry Nodes: 2.5D workflows](https://www.youtube.com/watch?v=j5Nh3D6tIlA) (BCON25, 37:09, 2025-09-19) | Michael Weigl, technical directing student, Animationsinstitut, Filmakademie Baden-Wuerttemberg; trained 2D animator and 3D generalist | not visible (likely 4.4 or 4.5) | 2.5D architecture: canvases carrying drawings, group-then-sort ordering, LOD blending, GN relighting, baking back to editable strokes; judging in motion | readability and motion `[05:55]` to `[07:35]`; sorting `[12:27]` to `[14:34]`; stages `[16:10]` to `[17:16]`; LOD `[19:10]`; bake editable `[19:42]`; cobblestone Q&A `[23:16]` to `[24:21]`; one bounce `[28:13]`; per-stroke shading `[35:09]` | | [Grease Pencil Quick Start Guide](https://www.youtube.com/watch?v=JOg3OWU6pmg) (50:37, 2025-07-30) | Elijah Sheffield, 3D motion designer, author of the SweetButtons Pro add-on | GP v3, likely 4.4 or 4.5 | Hybrid motion graphics: mesh shape layers + Line Art + drawn details; scene numbers; first-key and additive-vs-replacement pitfalls; blink and effect timing | scene setup `[01:23]` to `[05:45]`; shape meshes `[06:17]` to `[14:26]`; Line Art `[17:40]` to `[19:52]`; detail drawing and first key `[21:31]` to `[26:23]`; additive vs replace `[34:23]` to `[35:28]`; blink `[36:01]` to `[38:45]`; rain timing `[39:16]` to `[45:53]`; splash FX `[45:53]` to `[48:06]` | Version facts: `sources/blender-version-deltas.md` section 17 (Grease Pencil v3) and the release notes cited there (4.3 migration, 4.5, 5.0, 5.1 fill workflow, 5.2). Gaps: no source covers GP weight painting or the GP armature workflow in 5.x in depth (Tom argues against it for drawn characters); no source shows Weigl's LOD solver as code (marked [verify] in `expert-notes.md`); no source covers GP vertex paint or textured brushes.
-
-
scripts
-
bx_gp.py 46.1 KB
""" bx_gp: Grease Pencil v3 from Python for Blender 5.2 (tested on 5.2.1 LTS, headless and GUI). Builds 2D/2.5D drawings and frame-by-frame animation as DATA (layers, frames, drawings, stroke attributes), checks them the way 2D leads do, and renders them for review. Real draw/sculpt brushes are NOT scriptable in 5.2.1 (no exec, see the live-session section): strokes are written as data, sculpt/eraser work is done with grab / dissolve / edit_op, and a live session adds gui_trim / gui_erase_box / gui_reproject. import sys; sys.path.append("<skill>/scripts"); import bx_gp as G cam = G.scene_2d(res=(1080, 1080), fps=12, frame_range=(1, 24), ortho_scale=6) ob = G.new_object("Hero", layers=("Fills", "Shadow", "Lines")) # bottom -> top, unlit ink = G.material(ob, "Ink", stroke="#1a1a1a") skin = G.material(ob, "Skin", fill="#f2b8a0") d = G.key(ob, "Fills", 1) # get-or-create drawing G.add_shape(d, G.circle(1.0), holes=[G.circle(0.4)], material=skin) d = G.key(ob, "Lines", 1) G.add_stroke(d, G.smooth_path([(-1, 0, 0), (0, 0, .5), (1, 0, 0)]), radius=0.03, pressure=G.taper(25), brush="INK_PEN", material=ink) G.key(ob, "Lines", 7, mode="instance", source=1) # shared drawing (cycles, holds) G.end_exposure(ob, "Lines", 13) # blank key ends the drawing print(G.match(dA, dB)); G.interpolate(ob, "Lines", 1, step=2) # native in-betweens G.inbetween(ob, "Lines", 1, 9, frames=(3, 5, 7), ease="EASE_IN_OUT") # data in-betweens rep = G.report(ob); print(G.verdict(rep)) G.render_strip("/abs/strip.png", frames=range(1, 25, 2)) # onion-skin substitute G.onion("/abs/onion.png", 5, before=(3,), after=(7,)) Conventions: drawing plane XZ (front view), camera at -Y looking +Y. Distances in metres. `radius` is HALF the line width. Tuple colors are scene-linear RGBA; hex strings ("#RRGGBB") are sRGB and converted (use hex for palette colors). """ __version__ = "0.1" # Blender Expert Skills v0.1 (2026-09-24) import math import os import sys import tempfile import bpy import numpy as np from mathutils import Matrix, Vector HERE = os.path.dirname(os.path.abspath(__file__)) EXPERT_SCRIPTS = os.path.abspath(os.path.join(HERE, "..", "..", "scenario-blender-expert", "scripts")) GP_BRUSH_FILES = { "PAINT_GREASE_PENCIL": "essentials_brushes-gp_draw.blend", "SCULPT_GREASE_PENCIL": "essentials_brushes-gp_sculpt.blend", "VERTEX_GREASE_PENCIL": "essentials_brushes-gp_vertex.blend", "WEIGHT_GREASE_PENCIL": "essentials_brushes-gp_weight.blend", } RADIUS_DEFAULT = 0.01 # radius a point has while the 'radius' attribute does not exist BRUSH_LIKE = ("PEN", "INK_PEN", "PENCIL") # constant / pressure->radius / pressure->radius+opacity # --------------------------------------------------------------------------- colors def srgb(c, alpha=1.0): """'#RRGGBB' (sRGB) or an RGB(A) tuple (already linear) -> linear RGBA tuple.""" if isinstance(c, str): h = c.lstrip("#") rgb = [int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)] lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in rgb] a = int(h[6:8], 16) / 255.0 if len(h) == 8 else alpha return (lin[0], lin[1], lin[2], a) c = tuple(c) return c if len(c) == 4 else (c[0], c[1], c[2], alpha) # --------------------------------------------------------------------------- scene def scene_2d(res=(1920, 1080), fps=24, frame_range=(1, 48), ortho_scale=8.0, background="#ffffff", transparent=False, engine="BLENDER_EEVEE", cam_name="GP_Camera", scene=None, ambient=None): """Flat 2D stage: ortho camera at (0,-10,0) looking +Y onto the XZ plane, Standard view transform (predictable colors), background color. `ambient` (0..1 grey or color) sets World.color, the ambient that LIT GP layers receive (not the node tree).""" sc = scene or bpy.context.scene r = sc.render r.engine = engine r.resolution_x, r.resolution_y = res r.resolution_percentage = 100 r.fps, r.fps_base = fps, 1.0 sc.frame_start, sc.frame_end = frame_range r.film_transparent = transparent sc.view_settings.view_transform = "Standard" sc.view_settings.look = "None" if sc.world is None: sc.world = bpy.data.worlds.new("World") bg = srgb(background) nt = sc.world.node_tree node = nt.nodes.get("Background") if nt else None if node is not None: node.inputs["Color"].default_value = bg if ambient is not None: a = (ambient,) * 3 if isinstance(ambient, (int, float)) else srgb(ambient)[:3] sc.world.color = a cam = bpy.data.objects.get(cam_name) if cam is None: cam = bpy.data.objects.new(cam_name, bpy.data.cameras.new(cam_name)) sc.collection.objects.link(cam) cam.data.type = "ORTHO" cam.data.ortho_scale = ortho_scale cam.data.clip_start, cam.data.clip_end = 0.1, 100.0 cam.location = (0.0, -10.0, 0.0) cam.rotation_euler = (math.radians(90), 0.0, 0.0) sc.camera = cam return cam def px_size(scene=None, depth=None): """World size of one rendered pixel. Ortho: at any depth. Perspective: at `depth` metres in front of the camera (required).""" sc = scene or bpy.context.scene cam = sc.camera r = sc.render n = max(r.resolution_x, r.resolution_y) * r.resolution_percentage / 100.0 if cam.data.type == "ORTHO": return cam.data.ortho_scale / n if depth is None: raise ValueError("perspective camera: pass depth (distance along the view axis)") return 2.0 * depth * math.tan(cam.data.angle / 2.0) / n # --------------------------------------------------------------------------- objects, layers, materials def new_object(name, layers=("Fills", "Lines"), lit=False, collection=None, location=(0, 0, 0)): """Get-or-create a GP object. Layers are created bottom -> top in the given order. lit=False sets use_lights=False (flat color); new layers default to True, which renders flat colors dark unless World.color or lamps light them.""" ob = bpy.data.objects.get(name) if ob is None or ob.type != "GREASEPENCIL": gp = bpy.data.grease_pencils.new(name) ob = bpy.data.objects.new(name, gp) (collection or bpy.context.scene.collection).objects.link(ob) ob.location = location for n in layers: layer(ob, n, lit=lit) return ob def layer(ob, name, lit=False, below=None, above=None): """Get-or-create a layer (new layers go on top), optionally moved directly below or above another layer. Returns the layer. Stack order: ob.data.layers, bottom -> top.""" gp = ob.data lay = gp.layers.get(name) if lay is None: lay = gp.layers.new(name, set_active=False) lay.use_lights = lit ref = below or above if ref is not None: idx = lambda n: [l.name for l in gp.layers].index(n) for _ in range(len(gp.layers) * 2): want = idx(ref) - 1 if below else idx(ref) + 1 if idx(name) == want: break gp.layers.move(gp.layers[name], "DOWN" if idx(name) > want else "UP") return gp.layers[name] def material(ob, name, stroke=None, fill=None, holdout=False): """Get-or-create a GP material ('paint bucket': editing it recolors every stroke that uses it), make sure the object has it, return its slot index. stroke/fill: hex sRGB or linear tuple. Whether a stroke is filled is decided per stroke (fill_id), not here.""" m = bpy.data.materials.get(name) if m is None: m = bpy.data.materials.new(name) if not m.is_grease_pencil: bpy.data.materials.create_gpencil_data(m) g = m.grease_pencil if stroke is not None: g.color = srgb(stroke) if fill is not None: g.fill_color = srgb(fill) g.use_fill_holdout = g.use_stroke_holdout = bool(holdout) mats = ob.data.materials for i, x in enumerate(mats): if x == m: return i mats.append(m) return len(mats) - 1 # --------------------------------------------------------------------------- frames def _layer(ob_or_layer, layer_name=None): if layer_name is None: return ob_or_layer return ob_or_layer.data.layers[layer_name] def key(ob, layer_name, frame, mode="blank", source=None, keyframe_type="KEYFRAME"): """Get-or-create the drawing keyed at `frame` (idempotent: an existing key is returned untouched). mode: 'blank' (replacement drawing), 'copy' (additive: starts as a copy of `source` or of the drawing exposed at `frame`), 'instance' (shares the source drawing: editing one edits all; for holds and cycles).""" lay = _layer(ob, layer_name) for f in lay.frames: if f.frame_number == frame: return f.drawing if mode == "blank": fr = lay.frames.new(frame) else: src = source if src is None: ex = lay.get_frame_at(frame) if ex is None: raise ValueError(f"no drawing exposed at frame {frame} to copy") src = ex.frame_number fr = lay.frames.copy(src, frame, instance_drawing=(mode == "instance")) fr.keyframe_type = keyframe_type return fr.drawing def end_exposure(ob, layer_name, frame): """Blank key: the layer shows nothing from `frame` on (ends an effect or a held drawing).""" return key(ob, layer_name, frame, mode="blank") def exposures(ob, layer_name): """[(frame, keyframe_type, n_strokes, drawing.user_count)] for one layer.""" lay = _layer(ob, layer_name) return [(f.frame_number, f.keyframe_type, len(f.drawing.strokes), f.drawing.user_count) for f in sorted(lay.frames, key=lambda f: f.frame_number)] def cycle(ob, layer_name, keys, start, end, step=2): """Repeat drawings `keys` (frame numbers, in order) as instances on `step`s from start to end. Existing keys are never overwritten. Returns frames created.""" lay = _layer(ob, layer_name) have = {f.frame_number for f in lay.frames} made = [] for i, f in enumerate(range(start, end + 1, step)): src = keys[i % len(keys)] if f in have or f == src: continue lay.frames.copy(src, f, instance_drawing=True) have.add(f) made.append(f) return made # --------------------------------------------------------------------------- polylines def circle(r=1.0, n=48, center=(0, 0, 0)): """Closed polyline in the XZ plane (no repeated end point: set cyclic=True).""" cx, cy, cz = center return [(cx + r * math.cos(2 * math.pi * i / n), cy, cz + r * math.sin(2 * math.pi * i / n)) for i in range(n)] def ellipse(rx, rz, n=48, center=(0, 0, 0), rot=0.0): cx, cy, cz = center c, s = math.cos(rot), math.sin(rot) out = [] for i in range(n): a = 2 * math.pi * i / n x, z = rx * math.cos(a), rz * math.sin(a) out.append((cx + c * x - s * z, cy, cz + s * x + c * z)) return out def rect(w, h, center=(0, 0, 0), n_side=8): """Closed rectangle with n_side points per side (enough points to deform or sculpt).""" cx, cy, cz = center x0, x1, z0, z1 = cx - w / 2, cx + w / 2, cz - h / 2, cz + h / 2 corners = [(x0, z0), (x1, z0), (x1, z1), (x0, z1)] out = [] for k in range(4): (ax, az), (bx, bz) = corners[k], corners[(k + 1) % 4] for i in range(n_side): t = i / n_side out.append((ax + (bx - ax) * t, cy, az + (bz - az) * t)) return out def xz(points_2d, y=0.0): """[(x, z)] design coordinates -> [(x, y, z)] on the drawing plane.""" return [(p[0], y, p[1]) for p in points_2d] def smooth_path(control, samples=8, closed=False): """Centripetal Catmull-Rom through control points -> dense polyline. What a confident hand stroke looks like when you only know a few key positions.""" P = [Vector(p) for p in control] if len(P) < 3: return [tuple(p) for p in P] if closed: pts = [P[-1]] + P + [P[0], P[1]] segs = len(P) else: pts = [2 * P[0] - P[1]] + P + [2 * P[-1] - P[-2]] segs = len(P) - 1 out = [] for i in range(segs): p0, p1, p2, p3 = pts[i], pts[i + 1], pts[i + 2], pts[i + 3] t0 = 0.0 t1 = t0 + max((p1 - p0).length ** 0.5, 1e-6) t2 = t1 + max((p2 - p1).length ** 0.5, 1e-6) t3 = t2 + max((p3 - p2).length ** 0.5, 1e-6) for k in range(samples): t = t1 + (t2 - t1) * k / samples a1 = (t1 - t) / (t1 - t0) * p0 + (t - t0) / (t1 - t0) * p1 a2 = (t2 - t) / (t2 - t1) * p1 + (t - t1) / (t2 - t1) * p2 a3 = (t3 - t) / (t3 - t2) * p2 + (t - t2) / (t3 - t2) * p3 b1 = (t2 - t) / (t2 - t0) * a1 + (t - t0) / (t2 - t0) * a2 b2 = (t3 - t) / (t3 - t1) * a2 + (t - t1) / (t3 - t1) * a3 out.append(tuple((t2 - t) / (t2 - t1) * b1 + (t - t1) / (t2 - t1) * b2)) if not closed: out.append(tuple(P[-1])) return out def resample(points, n, closed=False): """Arc-length resample to exactly n points (gives two drawings matching point counts, the condition for clean interpolation).""" P = np.array(points, dtype=np.float64) if closed: P = np.vstack([P, P[:1]]) seg = np.linalg.norm(np.diff(P, axis=0), axis=1) s = np.concatenate([[0.0], np.cumsum(seg)]) total = s[-1] ts = np.linspace(0.0, total, n, endpoint=not closed) if not closed else np.arange(n) * total / n out = np.empty((n, 3)) for k in range(3): out[:, k] = np.interp(ts, s, P[:, k]) return [tuple(p) for p in out] def taper(n, start=0.25, end=0.35, floor=0.08, power=0.7): """Pressure profile 0..1 for n points: ramps up over `start` fraction, down over `end` fraction, `floor` at the tips. Written into radius (INK_PEN) or radius and opacity (PENCIL) by add_stroke: the headless stand-in for pen pressure.""" out = [] for i in range(n): t = i / max(n - 1, 1) a = min(1.0, t / start) if start > 0 else 1.0 b = min(1.0, (1.0 - t) / end) if end > 0 else 1.0 out.append(floor + (1.0 - floor) * (min(a, b) ** power)) return out # --------------------------------------------------------------------------- strokes _WIDTH = {"FLOAT": 1, "FLOAT_VECTOR": 3, "FLOAT_COLOR": 4} _KEY = {"FLOAT": "value", "FLOAT_VECTOR": "vector", "FLOAT_COLOR": "color"} def _write_points(d, name, dtype, start, values, default): """Write values for points [start:] of a point attribute, creating it safely. Trap: attributes.new('radius') zero-fills existing points (they vanish), so fill the defaults first.""" n = len(d.attributes["position"].data) w = _WIDTH[dtype] buf = np.empty(n * w, dtype=np.float32) a = d.attributes.get(name) if a is None: d.attributes.new(name, dtype, "POINT") buf[:] = np.tile(np.asarray(default, dtype=np.float32).reshape(-1), n) else: a.data.foreach_get(_KEY[dtype], buf) buf[start * w:] = np.asarray(values, dtype=np.float32).reshape(-1) d.attributes[name].data.foreach_set(_KEY[dtype], buf) def add_strokes(d, specs): """Bulk add. specs: list of dicts with add_stroke's keyword names (points required). One add_strokes call and one array write per attribute: fast for thousands of strokes.""" specs = [dict(s) for s in specs if len(s["points"]) > 0] if not specs: return [] n0_pts = len(d.attributes["position"].data) if "position" in d.attributes else 0 n0_str = len(d.strokes) d.add_strokes([len(s["points"]) for s in specs]) pos, rad, opa, vcol = [], [], [], [] need_vc = any(s.get("vertex_color") is not None for s in specs) for s in specs: pts = np.asarray(s["points"], dtype=np.float32) n = len(pts) pos.append(pts) prs = s.get("pressure") prs = np.ones(n) if prs is None else (np.full(n, float(prs)) if np.isscalar(prs) else np.asarray(prs, float)) like = s.get("brush", "INK_PEN") if like not in BRUSH_LIKE: raise ValueError(f"brush must be one of {BRUSH_LIKE}") r = np.full(n, s.get("radius", 0.01), float) o = np.full(n, s.get("opacity", 1.0), float) if like in ("INK_PEN", "PENCIL"): r = r * prs if like == "PENCIL": o = o * prs rad.append(r) opa.append(o) vc = s.get("vertex_color") vcol.append(np.tile(srgb(vc) if vc is not None else (0, 0, 0, 0), (n, 1))) _write_points(d, "position", "FLOAT_VECTOR", n0_pts, np.concatenate(pos), (0, 0, 0)) _write_points(d, "radius", "FLOAT", n0_pts, np.concatenate(rad), RADIUS_DEFAULT) _write_points(d, "opacity", "FLOAT", n0_pts, np.concatenate(opa), 1.0) if need_vc: _write_points(d, "vertex_color", "FLOAT_COLOR", n0_pts, np.concatenate(vcol), (0, 0, 0, 0)) out = [] for i, s in enumerate(specs): st = d.strokes[n0_str + i] st.material_index = s.get("material", 0) st.cyclic = bool(s.get("cyclic", False)) st.fill_id = int(s.get("fill_id", 0)) st.hide_stroke = bool(s.get("hide_stroke", False)) if s.get("softness"): st.softness = s["softness"] if s.get("fill_color") is not None: st.fill_color = srgb(s["fill_color"]) out.append(n0_str + i) d.tag_positions_changed() return out def add_stroke(d, points, radius=0.01, pressure=None, brush="INK_PEN", opacity=1.0, material=0, cyclic=False, fill_id=0, hide_stroke=False, vertex_color=None, softness=0.0, fill_color=None): """Add one stroke from a polyline. radius = half line width (m). pressure: None, a float, or a per-point list (see taper()). brush: 'PEN' ignores pressure (constant width, matches Line Art), 'INK_PEN' pressure->radius, 'PENCIL' pressure->radius and opacity. fill_id != 0 fills (same id = one fill, even-odd holes). Returns stroke index.""" return add_strokes(d, [dict(points=points, radius=radius, pressure=pressure, brush=brush, opacity=opacity, material=material, cyclic=cyclic, fill_id=fill_id, hide_stroke=hide_stroke, vertex_color=vertex_color, softness=softness, fill_color=fill_color)])[0] def next_fill_id(d): ids = [s.fill_id for s in d.strokes] return (max(ids) + 1) if ids else 1 def add_shape(d, outline, holes=(), material=0, line=False, radius=0.01, fill_id=None): """Closed filled shape with optional holes, all strokes sharing one fill_id (even-odd). line=False: fill only (hide_stroke), for a Fills layer under a separate Lines layer. line=True: the same curves also draw their outline with the material stroke color. Returns the fill_id used.""" fid = fill_id or next_fill_id(d) specs = [dict(points=p, radius=radius, brush="PEN", material=material, cyclic=True, fill_id=fid, hide_stroke=not line) for p in [outline, *holes]] add_strokes(d, specs) return fid def drawing_stats(d): """Counts, radius range and XZ bounding box of a drawing (compact dict).""" n = len(d.strokes) if n == 0: return {"strokes": 0, "points": 0} P = np.empty(len(d.attributes["position"].data) * 3, np.float32) d.attributes["position"].data.foreach_get("vector", P) P = P.reshape(-1, 3) rads = [p.radius for s in d.strokes for p in s.points] return {"strokes": n, "points": len(P), "radius_min": round(min(rads), 5), "radius_max": round(max(rads), 5), "bbox_min": [round(float(v), 3) for v in P.min(0)], "bbox_max": [round(float(v), 3) for v in P.max(0)], "fill_ids": sorted({s.fill_id for s in d.strokes})} # --------------------------------------------------------------------------- interpolation def _arrays(d): n = len(d.attributes["position"].data) if "position" in d.attributes else 0 P = np.zeros(n * 3, np.float32) if n: d.attributes["position"].data.foreach_get("vector", P) R = np.full(n, RADIUS_DEFAULT, np.float32) O = np.ones(n, np.float32) if "radius" in d.attributes: d.attributes["radius"].data.foreach_get("value", R) if "opacity" in d.attributes: d.attributes["opacity"].data.foreach_get("value", O) return P.reshape(-1, 3), R, O def match(da, db, eps=1e-6): """Tom Viguier's interpolability rule: 'copy' (identical), 'sculpted' (same stroke count, same point count per stroke, same material per stroke: interpolate), or 'different' (redraw or clip to the nearest drawing). Returns (state, reason).""" if len(da.strokes) != len(db.strokes): return "different", f"stroke count {len(da.strokes)} vs {len(db.strokes)}" for i, (a, b) in enumerate(zip(da.strokes, db.strokes)): if len(a.points) != len(b.points): return "different", f"stroke {i}: {len(a.points)} vs {len(b.points)} points" if a.material_index != b.material_index: return "different", f"stroke {i}: material {a.material_index} vs {b.material_index}" Pa, Ra, _ = _arrays(da) Pb, Rb, _ = _arrays(db) if np.allclose(Pa, Pb, atol=eps) and np.allclose(Ra, Rb, atol=eps): return "copy", "identical drawings (hold or instance)" return "sculpted", "same strokes and points: interpolable" def _ease(t, ease): if ease == "LINEAR": return t if ease == "EASE_IN": return t * t if ease == "EASE_OUT": return 1 - (1 - t) ** 2 if ease == "EASE_IN_OUT": return t * t * (3 - 2 * t) raise ValueError("ease: LINEAR, EASE_IN, EASE_OUT, EASE_IN_OUT") def interpolate(ob, layer_name, frame, step=2, type="LINEAR", easing="AUTO", all_layers=False, require_match=True): """Native in-betweens (grease_pencil.interpolate_sequence, runs headless) for the segment between the keys around `frame`. Inserts BREAKDOWN keys on `step`s. Does not key object transforms. require_match: refuse pairs that match() calls 'different'.""" lay = _layer(ob, layer_name) keys = sorted(f.frame_number for f in lay.frames if f.keyframe_type != "BREAKDOWN") prev = max([k for k in keys if k <= frame], default=None) nxt = min([k for k in keys if k > frame], default=None) if prev is None or nxt is None: raise ValueError(f"frame {frame} is not between two keys of {layer_name}: {keys}") state, why = match(lay.get_frame_at(prev).drawing, lay.get_frame_at(nxt).drawing) if require_match and state == "different": raise ValueError(f"{prev}->{nxt} not interpolable ({why}); redraw or use inbetween(clip=True)") sc = bpy.context.scene old = sc.frame_current vl = bpy.context.view_layer vl.objects.active = ob ob.data.layers.active = lay sc.frame_set(prev) r = bpy.ops.grease_pencil.interpolate_sequence(step=step, layers="ALL" if all_layers else "ACTIVE", type=type, easing=easing, exclude_breakdowns=True) sc.frame_set(old) return {"result": r, "segment": (prev, nxt), "match": state, "frames": [f for f in exposures(ob, layer_name) if prev < f[0] < nxt]} def inbetween(ob, layer_name, a, b, frames, spacing=None, ease="LINEAR", clip=False, keyframe_type="BREAKDOWN"): """Data in-betweens between keys a and b with the animator's spacing: `spacing` gives t (0..1) per frame, else `ease` (LINEAR, EASE_IN, EASE_OUT, EASE_IN_OUT) on linear time. Pairs that are not 'sculpted' raise, or with clip=True reuse the nearest key as an instance instead of distorting (Tom's clip mode). Existing keys are kept.""" lay = _layer(ob, layer_name) fa, fb = lay.get_frame_at(a), lay.get_frame_at(b) if fa is None or fb is None or fa.frame_number != a or fb.frame_number != b: raise ValueError("a and b must be keyed frames") da, db = fa.drawing, fb.drawing state, why = match(da, db) have = {f.frame_number for f in lay.frames} made = [] for i, f in enumerate(frames): if f in have: continue t = spacing[i] if spacing is not None else _ease((f - a) / (b - a), ease) if state == "different" or state == "copy": if state == "different" and not clip: raise ValueError(f"{a}->{b} not interpolable ({why}); pass clip=True or redraw") fr = lay.frames.copy(a if t < 0.5 else b, f, instance_drawing=True) fr.keyframe_type = keyframe_type made.append((f, "clip" if state == "different" else "hold")) continue Pa, Ra, Oa = _arrays(da) Pb, Rb, Ob = _arrays(db) d = key(ob, layer_name, f, keyframe_type=keyframe_type) sizes = [len(s.points) for s in da.strokes] d.add_strokes(sizes) _write_points(d, "position", "FLOAT_VECTOR", 0, Pa + (Pb - Pa) * t, (0, 0, 0)) _write_points(d, "radius", "FLOAT", 0, Ra + (Rb - Ra) * t, RADIUS_DEFAULT) _write_points(d, "opacity", "FLOAT", 0, Oa + (Ob - Oa) * t, 1.0) for s_out, s_in in zip(d.strokes, da.strokes): s_out.material_index = s_in.material_index s_out.cyclic = s_in.cyclic s_out.fill_id = s_in.fill_id s_out.hide_stroke = s_in.hide_stroke d.tag_positions_changed() made.append((f, round(t, 3))) return {"match": state, "why": why, "made": made} # --------------------------------------------------------------------------- depth (cutout, multiplane) def _view(cam): mw = cam.matrix_world return mw.translation.copy(), (mw.to_quaternion() @ Vector((0, 0, -1))).normalized() def draw_order(objs, cam=None): """Names front -> back by origin distance along the camera axis (with the default 2D stroke depth order, the nearer object draws on top).""" cam = cam or bpy.context.scene.camera bpy.context.view_layer.update() c, fwd = _view(cam) return [o.name for o in sorted(objs, key=lambda o: (o.matrix_world.translation - c).dot(fwd))] def push_depth(ob, delta, cam=None): """Move an object `delta` metres further from the camera (negative = nearer) WITHOUT changing its image: ortho = translate along the view axis; perspective = scale about the camera center by d_new/d_old. Works on parented objects. [added]""" cam = cam or bpy.context.scene.camera bpy.context.view_layer.update() c, fwd = _view(cam) M = ob.matrix_world.copy() if cam.data.type == "ORTHO": new = Matrix.Translation(fwd * delta) @ M else: d = (M.translation - c).dot(fwd) k = (d + delta) / d new = Matrix.Translation(c) @ Matrix.Scale(k, 4) @ Matrix.Translation(-c) @ M ob.matrix_world = new bpy.context.view_layer.update() return ob.matrix_world.translation.copy() # --------------------------------------------------------------------------- 2.5D helpers def project_to_surface(points, target, cam=None, offset=0.005): """Place stroke points ON a mesh as seen from the camera (the headless 'Surface' placement, and Pablo Fournier's sticky strokes at bind time). Returns (hits, missed, captured): hits = projected points offset toward the camera; missed = indices with no surface under them (they would 'go to oblivion'); captured = indices whose first hit is another object (an occluder).""" sc = bpy.context.scene cam = cam or sc.camera dg = bpy.context.evaluated_depsgraph_get() c, fwd = _view(cam) ortho = cam.data.type == "ORTHO" hits, missed, captured = [], [], [] for i, p in enumerate(points): p = Vector(p) if ortho: # parallel rays along the view axis origin, direction = p - fwd * 50.0, fwd else: origin, direction = c, (p - c).normalized() ok, loc, nrm, _idx, hit_ob, _m = sc.ray_cast(dg, origin, direction) if not ok: missed.append(i) hits.append(tuple(p)) continue if hit_ob.original != target: captured.append(i) hits.append(tuple(loc - direction * offset)) return hits, missed, captured def lineart(source=None, name="LineArt", radius=0.006, color="#141414", layer_name="Lines", depth_offset=None, fill=False): """GP object with a Line Art modifier. source: an object, a collection or None (scene; objects hidden from render are ignored). Measured on 5.2.1: the generated points get radius = modifier radius / 2, so the modifier `radius` IS the line width in metres. To match a drawn stroke of radius r, pass radius=2*r; for w pixels, w * px_size(). The default 0.0025 m is a hairline at typical framings. Bake to strokes with bpy.ops.object.lineart_bake_strokes() after setting the scene frame range.""" ob = new_object(name, layers=(layer_name,)) mi = material(ob, name + "_Ink", stroke=color, fill=color) md = ob.modifiers.get("LineArt") or ob.modifiers.new("LineArt", "LINEART") if source is None: md.source_type = "SCENE" elif isinstance(source, bpy.types.Collection): md.source_type = "COLLECTION" md.source_collection = source else: md.source_type = "OBJECT" md.source_object = source md.target_layer = layer_name md.target_material = ob.data.materials[mi] md.radius = radius if depth_offset is not None: md.stroke_depth_offset = depth_offset if fill and "fill_strokes" in md.bl_rna.properties: md.fill_strokes = True return ob def relight(ob, canvas, light_dir=(-0.6, -0.5, 0.6), shadow="#262a55", light="#ffc24a", low=-0.1, high=0.4, mode="STROKE", name="GP_Relight"): """Geometry Nodes relight of drawn strokes from a canvas mesh normal (a one-pass diffuse version of Michael Weigl's GN lighting passes): per point, dot(nearest canvas normal, light_dir) -> map low..high -> mix shadow..light -> Set Grease Pencil Color (mode STROKE or FILL). Returns the modifier.""" ng = bpy.data.node_groups.get(name) if ng is None: ng = bpy.data.node_groups.new(name, "GeometryNodeTree") ng.interface.new_socket("Geometry", in_out="INPUT", socket_type="NodeSocketGeometry") ng.interface.new_socket("Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") N, L = ng.nodes, ng.links gi, go = N.new("NodeGroupInput"), N.new("NodeGroupOutput") oi = N.new("GeometryNodeObjectInfo") oi.name = "Canvas" oi.transform_space = "RELATIVE" nrm = N.new("GeometryNodeInputNormal") sns = N.new("GeometryNodeSampleNearestSurface") sns.data_type = "FLOAT_VECTOR" dot = N.new("ShaderNodeVectorMath") dot.name = "Light" dot.operation = "DOT_PRODUCT" mr = N.new("ShaderNodeMapRange") mr.name = "Band" mix = N.new("ShaderNodeMix") mix.name = "Colors" mix.data_type = "RGBA" sgc = N.new("GeometryNodeSetGreasePencilColor") sgc.name = "SetColor" L.new(oi.outputs["Geometry"], sns.inputs["Mesh"]) L.new(nrm.outputs["Normal"], sns.inputs["Value"]) L.new(sns.outputs["Value"], dot.inputs[0]) L.new(dot.outputs["Value"], mr.inputs["Value"]) L.new(mr.outputs["Result"], mix.inputs["Factor"]) L.new(gi.outputs[0], sgc.inputs["Grease Pencil"]) L.new(mix.outputs["Result"], sgc.inputs["Color"]) L.new(sgc.outputs[0], go.inputs[0]) N = ng.nodes N["Canvas"].inputs["Object"].default_value = canvas N["Light"].inputs[1].default_value = Vector(light_dir).normalized() N["Band"].inputs["From Min"].default_value = low N["Band"].inputs["From Max"].default_value = high N["Colors"].inputs["A"].default_value = srgb(shadow) N["Colors"].inputs["B"].default_value = srgb(light) N["SetColor"].mode = mode md = ob.modifiers.get(name) or ob.modifiers.new(name, "NODES") md.node_group = ng return md # --------------------------------------------------------------------------- checks def report(ob, scene=None): """Measurable checks a 2D lead would make, as a dict (see verdict()).""" sc = scene or bpy.context.scene gp = ob.data has_lamps = any(o.type == "LIGHT" and not o.hide_render for o in sc.objects) amb = tuple(sc.world.color) if sc.world else (0.05, 0.05, 0.05) try: px = px_size(sc) if sc.camera and sc.camera.data.type == "ORTHO" else None except Exception: px = None used = set() rep = {"object": ob.name, "view_transform": sc.view_settings.view_transform, "px_size": px, "layers": [], "issues": [], "info": []} for lay in gp.layers: frames = sorted(lay.frames, key=lambda f: f.frame_number) L = {"name": lay.name, "use_lights": lay.use_lights, "blend": lay.blend_mode, "opacity": round(lay.opacity, 3), "hide": lay.hide, "lock": lay.lock, "masks": [m.name for m in lay.mask_layers] if lay.use_masks else [], "keys": [f.frame_number for f in frames]} rep["layers"].append(L) if lay.hide: continue if frames and frames[0].frame_number > sc.frame_start: rep["issues"].append(f"{lay.name}: first key {frames[0].frame_number} > frame_start " f"{sc.frame_start}: layer is empty before it") if lay.use_lights and not has_lamps and max(amb[:3]) < 0.5: rep["issues"].append(f"{lay.name}: use_lights=True, no lamps, World.color {tuple(round(a, 3) for a in amb)}: " "renders dark (set use_lights=False or raise World.color)") if lay.use_masks and len(lay.mask_layers) == 0: rep["issues"].append(f"{lay.name}: use_masks on but no mask layers") if len(lay.mask_layers) and not lay.use_masks: rep["info"].append(f"{lay.name}: mask layers set but use_masks is off") shared = [f.frame_number for f in frames if f.drawing.user_count > 1] if shared: rep["info"].append(f"{lay.name}: instanced drawings at {shared} (editing one edits all)") for f in frames: d = f.drawing for si, s in enumerate(d.strokes): used.add(s.material_index) npts = len(s.points) tag = f"{lay.name}@{f.frame_number} stroke {si}" if s.hide_stroke and s.fill_id == 0: rep["issues"].append(f"{tag}: hide_stroke with fill_id 0 is invisible") if s.fill_id != 0 and not s.cyclic: rep["info"].append(f"{tag}: filled but not cyclic (fill closes it anyway)") pts = [p.position for p in s.points] length = sum((Vector(pts[k + 1]) - Vector(pts[k])).length for k in range(npts - 1)) if npts == 1 or length < 3 * (px or 0.002): rep["issues"].append(f"{tag}: stray micro-stroke ({npts} points, {length:.4f} m long)") mi = s.material_index if mi >= len(gp.materials) or gp.materials[mi] is None: rep["issues"].append(f"{tag}: material index {mi} has no material") continue g = gp.materials[mi].grease_pencil if s.fill_id != 0 and g.fill_color[3] < 0.01: rep["issues"].append(f"{tag}: filled but material '{gp.materials[mi].name}' fill alpha is 0") if not s.hide_stroke and g.color[3] < 0.01: rep["issues"].append(f"{tag}: visible stroke but material '{gp.materials[mi].name}' stroke alpha is 0") if px and not s.hide_stroke: rmin = min(p.radius for p in s.points) if 2 * rmin / px < 1.0 and 2 * max(p.radius for p in s.points) / px < 1.0: rep["issues"].append(f"{tag}: line thinner than 1 px (radius {rmin:.4f}, px {px:.4f})") unused = [m.name for i, m in enumerate(gp.materials) if m and i not in used] if unused: rep["info"].append(f"unused materials: {unused}") if sc.view_settings.view_transform != "Standard": rep["info"].append(f"view transform {sc.view_settings.view_transform}: palette colors will shift " "(Standard gives predictable flat color)") return rep def verdict(rep): return rep["issues"] or ["no measurable problems"] # --------------------------------------------------------------------------- render and review def render_frame(path, frame=None, scene=None, res_pct=None, transparent=None): """Render one frame to PNG and return the path (restores render settings).""" sc = scene or bpy.context.scene r = sc.render saved = (r.filepath, r.resolution_percentage, r.film_transparent, sc.frame_current) fmt = (r.image_settings.media_type, r.image_settings.file_format) try: if frame is not None: sc.frame_set(frame) if res_pct is not None: r.resolution_percentage = res_pct if transparent is not None: r.film_transparent = transparent r.image_settings.media_type = "IMAGE" r.image_settings.file_format = "PNG" r.filepath = path bpy.ops.render.render(write_still=True, scene=sc.name) finally: r.filepath, r.resolution_percentage, r.film_transparent, cur = saved r.image_settings.media_type = fmt[0] # 5.x: media_type before file_format r.image_settings.file_format = fmt[1] sc.frame_set(cur) return path def _load(path): im = bpy.data.images.load(path, check_existing=False) w, h = im.size a = np.array(im.pixels[:], dtype=np.float32).reshape(h, w, 4) bpy.data.images.remove(im) return a def _save(arr, path): h, w = arr.shape[:2] im = bpy.data.images.new("BX_gp_out", w, h, alpha=True) im.pixels = arr.ravel() im.filepath_raw = path im.file_format = "PNG" im.save() bpy.data.images.remove(im) return path def render_strip(out_path, frames, cols=None, res_pct=50, scene=None): """Render frames and tile them left->right, top->bottom into one sheet (the headless substitute for flipping and onion skin: timing, spacing, pops). Legend next to it.""" if EXPERT_SCRIPTS not in sys.path: sys.path.append(EXPERT_SCRIPTS) import bx_review frames = list(frames) tmp = tempfile.mkdtemp(prefix="bx_gp_strip_") paths = [render_frame(os.path.join(tmp, f"f{f:04d}.png"), f, scene, res_pct) for f in frames] cols = cols or min(len(frames), 6) rows = -(-len(frames) // cols) while len(paths) < rows * cols: # pad with a blank tile blank = np.ones_like(_load(paths[0])) paths.append(_save(blank, os.path.join(tmp, f"blank{len(paths)}.png"))) return bx_review._tile(paths, rows, cols, out_path, [f"frame {f}" for f in frames]) def onion(out_path, frame, before=(), after=(), opacity=0.3, res_pct=50, scene=None, before_color=(0.1, 0.75, 0.25), after_color=(0.55, 0.25, 0.95)): """Rendered onion skin: neighbor frames as tinted ghosts (green before, purple after, like the viewport) under the current frame, on white. Unlike the viewport overlay it includes modifiers and object motion, the blind spots Falk David and Rik Schutte name.""" tmp = tempfile.mkdtemp(prefix="bx_gp_onion_") cur = _load(render_frame(os.path.join(tmp, "cur.png"), frame, scene, res_pct, transparent=True)) out = np.ones_like(cur) for frames, tint in ((before, before_color), (after, after_color)): for f in frames: g = _load(render_frame(os.path.join(tmp, f"g{f}.png"), f, scene, res_pct, transparent=True)) a = g[..., 3:4] * opacity out[..., :3] = out[..., :3] * (1 - a) + np.array(tint, np.float32) * a a = cur[..., 3:4] out[..., :3] = out[..., :3] * (1 - a) + cur[..., :3] * a out[..., 3] = 1.0 return _save(out, out_path) # --------------------------------------------------------------------------- headless edits (sculpt / eraser substitutes) def edit_op(ob, op, select="ALL", **kw): """Run a grease_pencil.* Edit-mode operator headless on every stroke of the visible, unlocked layers at the current frame, e.g. edit_op(ob, "stroke_smooth", iterations=5, factor=1.0), "stroke_simplify", "set_uniform_thickness", "stroke_subdivide", "set_curve_type", "outline", "join_fills", "separate_fills", "caps_set", "dissolve". Returns the operator result. View-dependent ops (stroke_trim, reproject, erase_*) need a live session: see gui_trim / gui_reproject / gui_erase_box.""" vl = bpy.context.view_layer vl.objects.active = ob ob.select_set(True) prev = ob.mode if prev != "EDIT": bpy.ops.object.mode_set(mode="EDIT") try: if select == "ALL": bpy.ops.grease_pencil.select_all(action="SELECT") mod, name = ("grease_pencil", op) if "." not in op else op.split(".") return getattr(getattr(bpy.ops, mod), name)(**kw) finally: bpy.ops.object.mode_set(mode=prev) def grab(d, center, radius, offset, strokes=None): """Sculpt-Grab equivalent on data: move points within `radius` of `center` by `offset` with a smooth falloff (Grant Abbitt fixes curves with Grab rather than redrawing). strokes: indices to affect (default all: lock by choosing).""" P, R, O = _arrays(d) c = np.asarray(center, np.float32) w = np.clip(1.0 - np.linalg.norm(P - c, axis=1) / radius, 0.0, 1.0) w = w * w * (3 - 2 * w) if strokes is not None: offs = [o.value for o in d.curve_offsets] mask = np.zeros(len(P), bool) for si in strokes: mask[offs[si]:offs[si + 1]] = True w = w * mask P = P + w[:, None] * np.asarray(offset, np.float32) _write_points(d, "position", "FLOAT_VECTOR", 0, P, (0, 0, 0)) d.tag_positions_changed() return int((w > 0).sum()) def dissolve(d, stroke_index, point_indices): """Remove points from one stroke and keep it continuous (Pablo Fournier: dissolve, never delete, stray points; deleting breaks the stroke in two).""" offs = [o.value for o in d.curve_offsets] a, b = offs[stroke_index], offs[stroke_index + 1] drop = {a + i for i in point_indices} keep = [k for k in range(len(d.attributes["position"].data)) if k not in drop] if b - a - len(drop) < 1: d.remove_strokes(indices=[stroke_index]) return 0 arrays = {} for name, dt, dflt in (("position", "FLOAT_VECTOR", (0, 0, 0)), ("radius", "FLOAT", RADIUS_DEFAULT), ("opacity", "FLOAT", 1.0), ("vertex_color", "FLOAT_COLOR", (0, 0, 0, 0))): if name in d.attributes: w = _WIDTH[dt] buf = np.empty(len(d.attributes[name].data) * w, np.float32) d.attributes[name].data.foreach_get(_KEY[dt], buf) arrays[name] = (dt, dflt, buf.reshape(-1, w)[keep]) d.resize_strokes(sizes=[b - a - len(drop)], indices=[stroke_index]) for name, (dt, dflt, arr) in arrays.items(): _write_points(d, name, dt, 0, arr, dflt) d.tag_positions_changed() return b - a - len(drop) # --------------------------------------------------------------------------- live session (view-dependent ops) # Verified on 5.2.1 GUI: grease_pencil.brush_stroke and sculpt_paint have NO exec # ("Invalid operator call", PASS_THROUGH): real draw/sculpt brushes cannot be driven # with a stroke list the way bx_gui drives mesh sculpt. Replayed mouse events # (Window.event_simulate with --enable-event-simulate) produced no stroke either. # Strokes are data (add_stroke); these view-dependent edit ops DO run with an override. def _gui(): if EXPERT_SCRIPTS not in sys.path: sys.path.append(EXPERT_SCRIPTS) import bx_gui if not bx_gui.has_window(): raise RuntimeError("needs a Blender window (live session); headless use the data helpers") return bx_gui def gui_frame(points, axis="FRONT", margin=1.6): """Point the first 3D view along `axis`, orthographic, framing world `points`.""" bx_gui = _gui() win, area, region, rv3d = bx_gui.view3d() bx_gui.run("view3d.view_axis", type=axis) P = np.array(points, float) lo, hi = P.min(0), P.max(0) rv3d.view_perspective = "ORTHO" rv3d.view_location = Vector(((lo + hi) / 2).tolist()) rv3d.view_distance = max(float(np.max(hi - lo)), 0.5) * margin rv3d.update() return region, rv3d def _enter(ob, mode): bx_gui = _gui() for o in bpy.context.selected_objects: o.select_set(False) ob.select_set(True) bpy.context.view_layer.objects.active = ob if ob.mode != mode: bx_gui.run("object.mode_set", mode=mode) return bx_gui def _to_region(region, rv3d, p): from bpy_extras import view3d_utils v = view3d_utils.location_3d_to_region_2d(region, rv3d, Vector(p)) return (v.x, v.y) def gui_trim(ob, lasso, axis="FRONT"): """LIVE SESSION ONLY. Trim tool: inside the lasso (world points seen from `axis`), cut overshooting stroke ends back to the nearest intersection (Grant Abbitt: overlap on purpose, then trim). Lock layers you do not want cut. Returns the op result.""" bx_gui = _enter(ob, "PAINT_GREASE_PENCIL") region, rv3d = gui_frame(lasso, axis) path = [{"name": "", "loc": _to_region(region, rv3d, p), "time": 0.0} for p in lasso] r = bx_gui.run("grease_pencil.stroke_trim", path=path) bx_gui.run("object.mode_set", mode="OBJECT") return r def gui_erase_box(ob, lo, hi, axis="FRONT"): """LIVE SESSION ONLY. Erase every point inside the screen box spanned by world points lo and hi (Draw mode box eraser; strokes are cut, not deleted whole).""" bx_gui = _enter(ob, "PAINT_GREASE_PENCIL") region, rv3d = gui_frame([lo, hi], axis, margin=3.0) a, b = _to_region(region, rv3d, lo), _to_region(region, rv3d, hi) r = bx_gui.run("grease_pencil.erase_box", xmin=int(min(a[0], b[0])), xmax=int(max(a[0], b[0])), ymin=int(min(a[1], b[1])), ymax=int(max(a[1], b[1])), wait_for_input=False) bx_gui.run("object.mode_set", mode="OBJECT") return r def gui_reproject(ob, type="SURFACE", offset=0.0, axis="FRONT", keep_original=False, frame_points=None): """LIVE SESSION ONLY. Reproject all strokes of the current frame from the view onto surfaces (SURFACE), the cursor plane or an axis plane. Headless alternative: project_to_surface() on the point positions.""" bx_gui = _enter(ob, "EDIT") if frame_points is not None: gui_frame(frame_points, axis) else: bx_gui.run("view3d.view_axis", type=axis) bx_gui.run("grease_pencil.select_all", action="SELECT") r = bx_gui.run("grease_pencil.reproject", type=type, offset=offset, keep_original=keep_original) bx_gui.run("object.mode_set", mode="OBJECT") return r
-
-
SKILL.md 17.6 KB
--- name: scenario-blender-grease-pencil description: "Use when drawing or animating with Grease Pencil in Blender 5.x from Python: 2D or 2.5D illustration, frame-by-frame animation, a cutout or part-based 2D character, strokes with pressure, filled shapes with holes, in-betweens and interpolation, onion skin, Line Art outlines, strokes on 3D meshes, NPR or hybrid motion graphics. Also when GP code fails (GPENCIL, frame.strokes, grease_pencil_modifiers), fills do not show, colors render dark, or draw brushes will not run from a script." license: MIT --- # Grease Pencil (v3, Blender 5.2) Expert Grease Pencil work keeps every drawing editable and redrawable, puts meaning in the structure (which object, which layer, which key) and judges the result in motion. The agent draws through the data API (drawings are curves with attributes), builds characters the way 2D studios rig them, and reviews rendered frames instead of a viewport it cannot flip. 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). Shot planning, boards and animatics: scenario-blender-previs-storyboard. Mesh animation principles: scenario-blender-animation. Toolkit: [`scripts/bx_gp.py`](scripts/bx_gp.py) (tested on 5.2.1). `import sys; sys.path.append("<this skill>/scripts"); import bx_gp as G`. ## Stance (the expert delta) - **Tom Viguier (Andarta, Ewilan's Quest): the rig must never block redrawing.** One GP object per physical element (about 60 for a lead character), parented, mostly FK by object transforms; layers inside each element by graphic logic (line above color); sculpting as the deformer; no armature weight painting on drawings, because every redraw would need new weights. - **Tom: interpolate only "sculpted" pairs.** Two keys interpolate cleanly only with the same number, order and color of strokes and the same points. Otherwise redraw, or "clip" to the nearest drawing instead of accepting distortion. Native interpolation ignores object animation: key transforms separately. - **Falk David (GP module lead): fill is geometry, not material.** A stroke fills when `fill_id != 0`; strokes sharing an id fill together with even-odd holes; `hide_stroke` hides the outline. Material fill toggles are deprecated and holdout "eraser fills" (Grant Abbitt's 4.4 method) are obsolete. - **Grant Abbitt: the layer stack carries meaning and materials are paint buckets.** Lines on top, a shadow layer (Multiply, lowered opacity, masked by the fills) in between, fills at the bottom. Editing a material recolors every stroke using it. Lock what you are not editing: erasers and sculpt act on all unlocked layers. - **Elijah Sheffield: predictable color first.** Standard view transform, flat unlit shading, white world. Verified trap: new layers have `use_lights=True`, and lit layers take their ambient from `World.color` (default 0.05), not from the world node tree, so a flat palette renders at about a quarter of its value. - **Tom: multiplane discipline and layout first.** Every 2D element parallel to the camera, a camera that never changes axis, 3D only where it saves time; camera, cuts and parallax are settled in layout, not discovered in compositing. Between objects, depth decides who draws on top. - **Michael Weigl: motion reveals the surface.** A painterly still breaks once it moves; judge 2.5D as a sequence. Sort groups of strokes (mesh islands), never individual strokes, and bake procedural output back to editable strokes. - **Pablo Fournier (Blender Studio): design the shadows.** Replace blobby 3D shading with blocky, drawn light and shadow shapes (a light and a shadow material per part). Fills are flat: use many small fills on curved surfaces, not one big fill pushed forward. ## Establish first | Input | Changes | Default when silent | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | Deliverable | still, frame-by-frame shot, cutout rig, hybrid motion graphics, 2.5D over 3D | frame-by-frame shot | | Format | resolution, fps, length | 1920 x 1080, 24 fps keyed on twos [added]; social motion graphics: 1080 x 1920, 12 fps, 60 frames (Elijah) | | Look | flat or lit, line system | flat (unlit layers, Standard); ink line with pressure taper | | Line system | PEN constant width (matches Line Art, Elijah) or INK_PEN pressure on radius (cartoony, Grant) or PENCIL (pressure on radius and opacity) | INK_PEN | | Palette | material colors | hex sRGB values, one material per paint bucket | | Camera | ortho multiplane or perspective | ortho, front view, drawing plane XZ | | Who edits next | how much stays procedural | a human animator: everything bakes to editable strokes | ## Workflow 1. **Stage and layout.** `cam = G.scene_2d(res, fps, frame_range, ortho_scale)` (ortho camera at -Y, Standard, background). Plan the multiplane: which elements are separate objects and their depth order. Compute `px = G.px_size()`; line width in pixels is `2 * radius / px`. GATE: a test render shows the frame; planned line widths are at least 1.5 px [added]. 2. **Structure.** `G.new_object(name, layers=("Fills", "Shadow", "Lines"))` per element (bottom to top, unlit), parent parts, place origins at joints. `G.material(ob, name, stroke=hex, fill=hex)` once per bucket. GATE: `[l.name for l in ob.data.layers]` matches intent; `G.draw_order(objs)` lists front to back as designed. 3. **Draw.** Keys first: `d = G.key(ob, "Lines", frame)`. Lines: `G.add_stroke(d, G.smooth_path(control_pts), radius, pressure=G.taper(n), brush="INK_PEN", material=i)`; overlap then trim rather than landing ends exactly (Grant). Fills: `G.add_shape(d, outline, holes=[...], material=i)` on the Fills layer, outline on Lines. Bulk: `G.add_strokes(d, specs)` (3000 strokes in 0.04 s). Fix shapes with `G.grab`, `G.dissolve`, `G.edit_op(ob, "stroke_smooth", ...)`. GATE: `G.verdict(G.report(ob))` is clean and a full-resolution `G.render_frame` shows no gaps, leaks or overhangs. 4. **Color and shadow.** Shadow layer: `blend_mode='MULTIPLY'`, opacity lowered, `use_masks=True`, `mask_layers.add(fills)`; paint the shadow shape past the edge and let the mask clip it; design it blocky (Pablo). Lit look: `use_lights=True` plus `World.color` as ambient plus lamps (verified: a sun only scales value on a flat drawing, a point light gives a gradient). GATE: sampled pixels match the palette under Standard. 5. **Animate.** Replacement key `G.key(..., mode="blank")`, additive `mode="copy"`, hold or cycle `mode="instance"` / `G.cycle(...)`, end an exposure with `G.end_exposure`. Every visible layer needs a key at or before `frame_start` (Elijah). Cutout motion: key object transforms (FK). In-betweens: check `G.match(dA, dB)`, then `G.interpolate(ob, layer, frame, step=2, type, easing)` (native, segment around `frame`) or `G.inbetween(ob, layer, a, b, frames, spacing=[...] or ease=...)` for the animator's own spacing, `clip=True` when the pair is different. GATE: `G.exposures(ob, layer)` per layer, `G.render_strip(path, frames)` read left to right for timing and pops, `G.onion(path, frame, before, after)` for arcs and spacing. 6. **2.5D (optional).** Outlines: `G.lineart(source, radius=width_m)` (the modifier radius is the line width). Strokes on meshes: `G.project_to_surface(pts, mesh)` and discard strokes with missed or occluder-captured points (Pablo's failure modes). Relight: `G.relight(ob, canvas, light_dir, shadow, light)`. Procedural strokes: Geometry Nodes Curves to Grease Pencil with Instances as Layers off and a `fill_id` attribute, then apply to bake (procedures P7). Depth reorder without changing the image: `G.push_depth(ob, delta)`. GATE: a moving render (strip or playblast) where the surface does not read as 3D (Weigl) and fills do not poke through (Pablo). 7. **Deliver.** Render frames or video with the scene camera; keep the .blend editable (no applied procedural stage the animator still needs). GATE: every object's verdict clean, strip and final frames looked at, list what was not verified. ## Numbers | Value | Meaning | Source | | -------------------------------------------------------------------- | --------------------------------------------------- | ------------------------- | | `radius` = half line width (m) | width px = 2 x radius / px_size | verified | | px_size = ortho_scale / max(res_x, res_y) | ortho; perspective uses depth x 2 tan(fov/2) / res | verified | | 0.01 m | point radius while the `radius` attribute is absent | verified | | Line Art `radius` default 0.0025 m | is the line WIDTH: points get radius/2 | verified | | Elijah's Line Art thickness 60 (4.x) | about 0.006 m width in 5.2 | [inferred] | | `use_lights` True, `World.color` 0.05 | new layers render flat colors at about 25 % value | verified | | `set_uniform_thickness(thickness=t)` | sets radius t/2 | verified | | ~60 parts, ~100 rig versions, 2 weeks rig for a lead | Ewilan's Quest cutout scale | Tom | | 12 fps, 60 frames, ortho scale 8, Subdivision 2 to 3 on shape meshes | hybrid motion graphics | Elijah | | Stabilizer 40 px / 0.9 bundled, Elijah uses ~60 on demand | only for long curves | Elijah, verified defaults | | 1 light bounce | enough for stylized relighting | Weigl | | GP anti-aliasing `aa_samples` 8 | scene.grease_pencil_settings | verified | ## Quality gates Measurable (per object, every stage): ```python for ob in gp_objects: print(ob.name, G.verdict(G.report(ob))) # first key vs frame_start, lit-without-light, # invisible strokes, stray micro-strokes, sub-pixel lines, # fill/stroke alpha 0, empty masks; info: instances, unused materials print(G.draw_order(gp_objects)) # front -> back vs the staging plan print(G.match(dA, dB)) # before any interpolation ``` Visual (open every image): `G.render_frame(path, f)` at full resolution for line quality, trims, fill gaps and palette; `G.render_strip(path, range(a, b, 2))` for timing, holds and pops; `G.onion(path, f, before, after)` for spacing and arcs (it includes object motion and modifiers, which the viewport onion skin ignores, Falk and Rik); for 2.5D a sequence, never a still. Score against [`references/critique.md`](references/critique.md). ## Common mistakes | Mistake | Looks like | Fix | | ---------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------- | | Layers left at `use_lights=True` with no lamps | flat palette renders dark brown or gray | `layer.use_lights=False`, or raise `World.color` and add lamps | | Fill expected from the material | shape outline only | `stroke.fill_id=1`, `cyclic=True`; holes share the id | | `hide_stroke=True` with `fill_id=0` | stroke vanishes | give it a fill id or show the stroke | | `attributes.new("radius")` on a drawing with strokes | existing strokes disappear (radius 0) | fill defaults first (`G.add_strokes` does) | | First key after frame_start | drawing pops in, empty frames at the start | key at frame_start (Elijah) | | Interpolating different drawings | smeared, twisted in-betweens | `G.match`; redraw, resample, or clip (Tom) | | Rigging drawings with armature weights | every redraw breaks deformation | object-level cutout, sculpt as deformer (Tom) | | Stroke-level depth sorting | artist's layer order destroyed | sort groups (objects or mesh islands) (Weigl) | | One big fill on a curved surface | fill pokes through the mesh | many small fills near the surface (Pablo) | | Deleting stray points | stroke splits in two | `G.dissolve` keeps it continuous (Pablo) | | Line Art radius set like a stroke radius | outline half the intended weight | Line Art radius = width = 2 x stroke radius | | Judging 2.5D on a still | looks painterly, reads as 3D in motion | render the sequence (Weigl) | | Calling draw brushes from a script | `Invalid operator call`, nothing drawn | write strokes as data; see 5.2 notes | ## Blender 5.2 notes - Types: object `GREASEPENCIL`, data `bpy.data.grease_pencils`; `layer.frames.new(n).drawing.add_strokes([...])`; old `frame.strokes`, `point.co`, `pressure`, `strength`, `GPENCIL` and `object.grease_pencil_modifiers` are gone. Modifiers are regular: `ob.modifiers.new(n, 'GREASE_PENCIL_NOISE')`, Line Art is `'LINEART'`. - Stroke and point helpers are Python wrappers: re-fetch after adding or removing strokes, never cache them. - `grease_pencil.brush_stroke` and `sculpt_paint` have no exec: called with a stroke list in a live session they log "Invalid operator call" and draw nothing (verified GUI); replayed mouse events via `event_simulate` also drew nothing. Draw and sculpt strokes are data; headless edit operators (`stroke_smooth`, `stroke_simplify`, `set_uniform_thickness`, `stroke_subdivide`, `outline`, `join_fills`, `set_curve_type`) run in Edit mode through `G.edit_op`. In a live session `G.gui_trim` (lasso Trim), `G.gui_erase_box` and `G.gui_reproject(type='SURFACE')` work with a 3D-view override. - `interpolate_sequence` runs headless on the active object and layer, fills only the segment around the current frame and inserts BREAKDOWN keys; easing via `type` and `easing`. - `frames.new` or `frames.copy` onto an existing frame raises "Frame already exists"; `get_frame_at(n)` returns the key exposed at n (None before the first key). - `layer.mask_layers.add(layer)` is the 5.2 mask API; layer groups `gp.layer_groups.new`, `gp.layers.move_to_layer_group`; layers can parent to an object or bone (`layer.parent`, `parent_bone`) and carry their own transform. - Line Art objects made by `object.grease_pencil_add(type='LINEART_OBJECT')` have lit layers; objects hidden from render are ignored by Line Art; `lineart_bake_strokes` bakes the whole scene range. - GN Curves to Grease Pencil drops plain curves while Instances as Layers is on (default); Set Material alone no longer fills (5.1): store an INT `fill_id` on the curve domain. ## References - [`references/expert-notes.md`](references/expert-notes.md): each expert's principles, judgment and disagreements with timestamps; load when making a style or pipeline decision. - [`references/procedures.md`](references/procedures.md): tested raw-bpy procedures (P1 to P8) and bx_gp recipes (cutout character, frame-by-frame, interpolation, 2.5D); load before writing GP code. - `references/critique.md`: the rubric for judging line, fill, staging, timing and 2.5D renders; load at every review gate. - [`references/sources.md`](references/sources.md): the five source videos, what each is best for, best timestamps.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.