Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion examples/skull/GAPS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Gaps found while drawing the skull
# Gaps found while drawing and animating the skull

These are places where the compiler couldn't express what the art needed, or
where it didn't behave the way the docs describe. Each one has a minimal repro
Expand Down Expand Up @@ -82,9 +82,40 @@ mismatched map, and that warns on every render. **Workaround:** the talking
poses `skull_jaw_ajar` and `skull_jaw_open` are written out in full as their
own sprites, not as `shift`-ed copies of `skull_jaw`.

GIF frames have the same problem. With `frames: ["dot", "dot_moved"]`,
`pxl render --gif` draws the second frame empty and warns that `dot_moved`
"uses deprecated grid format". **Workaround in `skull_anim.pxl`:** the 1px
head float needs the whole skull at two heights, so every sprite exists twice
(`*_rest` and `*_up`) with its coordinates written out.

## 5. Derived sprites still need `palette`

The docs example `{ type: "sprite", name: "hero_outlined", source: "hero",
transform: [...] }` fails with `missing field 'palette'`. In `skull.pxl`,
the composition after that line then wasn't found either. **Workaround:** not needed after gap 4, but any
`source:` sprite has to repeat `palette`.

## 6. No per-frame duration in a frame-list animation

A `frames: [...]` animation has one `duration` that applies to every frame.
`gif.rs::render_gif` takes a single `duration_ms`, and there's no per-frame
timing field (`frame_metadata` only carries hitboxes). Speech timing and idle
holds need uneven frame lengths. **Workaround:** a hold is the same
composition repeated (`skull_talking` repeats `skull` for its pauses). Nothing
is lost in the GIF, but the frame lists get long.

## 7. A palette `@include` breaks when a sprite is used directly as a GIF frame

```json5
// base.pxl
{ type: "palette", name: "p", colors: { _: "transparent", x: "#ff0000" } }
// anim.pxl
{ type: "sprite", name: "a", size: [4, 4], palette: "@include:base.pxl", regions: { x: { points: [[0, 0]] } } }
{ type: "animation", name: "an", frames: ["a", "a"], duration: 100 }
```

The first frame is right, but the second renders magenta with `Circular include detected: .../base.pxl` and
`Unknown token x`. The include-visited set seems to be shared across frames,
so the second lookup of the same file is treated as a cycle. The same include
works through a composition. **Workaround:** `skull_anim.pxl` repeats the
`bone` palette instead of including it from `skull.pxl`.
48 changes: 44 additions & 4 deletions examples/skull/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# Skull

A 3/4-view skull facing right, drawn in pixelsrc after `reference-skull.jpg`.
It's a 40x40 sprite on a transparent background, and the jaw is a separate
sprite so the skull can talk.
It's a 40x40 sprite on a transparent background. The jaw is a separate sprite,
so the skull can talk, laugh, float and glow.

![reference vs render](skull_comparison.png)

![talking](skull_talking.gif)
| idle | laugh | eyes | talking |
|------|-------|------|---------|
| ![idle](skull_idle.gif) | ![laugh](skull_laugh.gif) | ![eyes](skull_eyes.gif) | ![talking](skull_talking.gif) |

![contact sheet](skull_contact_sheet.png)

## Files

Expand All @@ -15,7 +19,13 @@ sprite so the skull can talk.
| `skull.pxl` | Source: the `bone` palette, the `skull_cranium` and `skull_jaw` sprites, two open-jaw poses (`skull_jaw_ajar`, `skull_jaw_open`), the `skull` / `skull_talk_*` compositions and the `skull_talking` animation |
| `skull.png` | The still at 1x (40x40) |
| `skull_8x.png` | The still at 8x (320x320) |
| `skull_talking.gif` | The talking loop at 8x |
| `skull_talking.gif` | Talking at 8x, with speech-like timing: 1-3 frame syllables and holds between phrases |
| `skull_anim.pxl` | Animation source on a 44x44 canvas (room to float and drop the jaw): the skull at two head heights, a wide-open jaw pose, blink and ember-glow overlays, and the `skull_idle`, `skull_laugh` and `skull_eyes` animations |
| `skull_idle.gif` | A slow 1px float, with one blink every other bob (8x) |
| `skull_laugh.gif` | Fast jaw chatter, the head tipping up a pixel on each open (8x) |
| `skull_eyes.gif` | The idle float with ember light flickering in the sockets (8x). The `glow_1..3` overlays sit on top of any frame |
| `skull_contact_sheet.png` | A few frames from each animation |
| `contact_sheet.py` | Builds the contact sheet from `pxl render --spritesheet` strips (needs Pillow) |
| `reference-skull.jpg` | The target art |
| `skull_comparison.png` | Reference and render side by side, both skulls scaled to the same height |
| `compare.py` | Builds `skull_comparison.png` (needs Pillow) |
Expand All @@ -31,6 +41,11 @@ $pxl render examples/skull/skull.pxl -c skull -o examples/skull/skull.png
$pxl render examples/skull/skull.pxl -c skull --scale 8 -o examples/skull/skull_8x.png
$pxl render examples/skull/skull.pxl --gif --animation skull_talking --scale 8 -o examples/skull/skull_talking.gif
python3 examples/skull/compare.py

for a in skull_idle skull_laugh skull_eyes; do
$pxl render examples/skull/skull_anim.pxl --gif --animation $a --scale 8 -o examples/skull/$a.gif
done
python3 examples/skull/contact_sheet.py
```

## How it's built
Expand All @@ -53,3 +68,28 @@ python3 examples/skull/compare.py
the jaw opens. The open poses swing the mandible around the hinge: the chin
drops 1px (ajar) or 2px (open) and the hinge stays put.
- **Order.** Every region has an explicit `z` (see GAPS.md).
- **Animation.** Compositions can't offset a layer, and derived `shift` sprites
don't survive into GIF frames (GAPS.md #4). So `skull_anim.pxl` writes the
cranium, jaw poses and overlays out at both head heights (`*_rest`, and
`*_up` 1px higher), and each frame is a composition of the three layers.
Moves are rigid and the jaw poses reuse the still's art, so the outline
never changes shape between frames. Holds are repeated frames, because an
animation has a single frame duration (GAPS.md #6).
- **Glow.** The `ember` palette adds four warm shades. `glow_1` warms the inner
socket walls, `glow_2` adds a coal and `glow_3` flares it. The blink overlays
darken the same walls to the socket colour, half and then full.

## Evolution

![The skull from the first attempt to now](evolution.png)

Animated, each version and then the four animations: [evolution.gif](evolution.gif)

Every version of this skull was written as `.pxl` and rendered by `pxl`. From left to right:

- **Reference:** the target art.
- **January 25:** the first pass and an iteration from the artisan workflow.
- **February 7:** round 2.
- **September 26:** a fleet agent's 48×48 pass (#12), then the version in this folder, which took twelve render-and-compare rounds against the reference (#13).

The earlier sources live in git history under `examples/avatar-prototype/`.
52 changes: 52 additions & 0 deletions examples/skull/contact_sheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Build skull_contact_sheet.png: a few frames from each skull animation.

cargo build --release && python3 examples/skull/contact_sheet.py

pxl renders every frame as a spritesheet (`pxl render --spritesheet`). This
script only picks frames, adds labels and stacks the rows. Needs Pillow.
"""
import subprocess
import tempfile
from pathlib import Path

from PIL import Image, ImageDraw

HERE = Path(__file__).parent
PXL = HERE.parent.parent / "target" / "release" / "pxl"
SCALE, CELL = 4, 44 # every frame is placed in a 44x44 cell, shown 4x

# (file, animation, frame size, frames to show, label)
ROWS = [
("skull_anim.pxl", "skull_idle", 44, [0, 5, 6, 11, 20, 21], "idle: 1px float, blink"),
("skull_anim.pxl", "skull_laugh", 44, [0, 1, 2, 3, 5, 7], "laugh: chatter + head tip"),
("skull_anim.pxl", "skull_eyes", 44, [0, 1, 3, 8, 12, 15], "eyes: ember glow flicker"),
("skull.pxl", "skull_talking", 40, [0, 1, 2, 5, 7, 16], "talking: speech timing"),
]

label_w, pad = 190, 8
cols = max(len(r[3]) for r in ROWS)
cell = CELL * SCALE
sheet = Image.new("RGB", (label_w + cols * (cell + pad) + pad, len(ROWS) * (cell + pad) + pad), "white")
draw = ImageDraw.Draw(sheet)

with tempfile.TemporaryDirectory() as tmp:
for row, (src, anim, size, frames, label) in enumerate(ROWS):
strip_path = Path(tmp) / f"{anim}.png"
subprocess.run(
[str(PXL), "render", str(HERE / src), "--spritesheet", "--animation", anim,
"--scale", str(SCALE), "-o", str(strip_path)],
check=True, capture_output=True,
)
strip = Image.open(strip_path).convert("RGBA")
y = pad + row * (cell + pad)
draw.text((pad, y + cell // 2 - 12), label, fill=(80, 76, 72))
draw.text((pad, y + cell // 2 + 4), f"{src} / {anim}", fill=(150, 144, 136))
inset = (CELL - size) * SCALE // 2
for col, f in enumerate(frames):
frame = strip.crop((f * size * SCALE, 0, (f + 1) * size * SCALE, size * SCALE))
x = label_w + col * (cell + pad)
sheet.paste(frame, (x + inset, y + inset), frame)
draw.text((x + 4, y + 2), f"#{f}", fill=(170, 164, 156))

sheet.save(HERE / "skull_contact_sheet.png")
print("Saved:", HERE / "skull_contact_sheet.png")
Binary file added examples/skull/evolution.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/skull/evolution.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 11 additions & 3 deletions examples/skull/skull.pxl
Original file line number Diff line number Diff line change
Expand Up @@ -422,11 +422,19 @@
layers: [{ map: ["C"] }, { map: ["J"] }],
}

// closed -> ajar -> open -> ajar, with a beat of silence
// Speech-like timing: syllables of 1-3 frames, short closes between them and a
// longer hold between phrases. GIF frames share one duration, so a hold is a
// repeated frame (see GAPS.md).
{
type: "animation",
name: "skull_talking",
frames: ["skull", "skull_talk_ajar", "skull_talk_open", "skull_talk_ajar", "skull", "skull_talk_open", "skull_talk_ajar", "skull", "skull"],
duration: 110,
frames: [
"skull", "skull_talk_ajar", "skull_talk_open", "skull_talk_ajar", "skull", "skull",
"skull_talk_ajar", "skull_talk_open", "skull_talk_open", "skull_talk_ajar", "skull", "skull_talk_ajar",
"skull", "skull", "skull", "skull_talk_ajar", "skull_talk_open", "skull_talk_ajar",
"skull_talk_open", "skull_talk_ajar", "skull", "skull", "skull", "skull",
"skull", "skull",
],
duration: 80,
loop: true,
}
Loading
Loading