feat(skills): expand touchdesigner-mcp with animation, MIDI/OSC, particles, projection refs
Adds four new reference docs covering common TD use cases not previously documented in the skill: - animation.md: LFOs, timers, keyframes, easing, time references - midi-osc.md: MIDI controllers, OSC routing, TouchOSC, multi-machine sync - particles.md: POPs and particleSOP — emission, forces, collisions, render - projection-mapping.md: windowCOMP, corner pin, mesh warp, edge blending Also clarifies the SKILL.md tool quick reference: adds td_screen_point_to_global and notes that 4 admin/dev-mode tools (td_project_quit, td_test_session, td_dev_log, td_clear_dev_log) live only in mcp-tools.md to keep the main reference focused on creative workflows. No SKILL.md workflow or critical-rules changes. References load on demand so no token-budget impact at session start.
This commit is contained in:
parent
94b26f3ec9
commit
02df438316
@ -204,8 +204,9 @@ win.par.winopen.pulse()
|
||||
| `td_input_clear` | Stop input automation |
|
||||
| `td_op_screen_rect` | Get screen coords of a node |
|
||||
| `td_click_screen_point` | Click a point in a screenshot |
|
||||
| `td_screen_point_to_global` | Convert screenshot pixel to absolute screen coords |
|
||||
|
||||
See `references/mcp-tools.md` for full parameter schemas.
|
||||
The table above covers the 32 tools used in typical creative workflows. The remaining 4 tools (`td_project_quit`, `td_test_session`, `td_dev_log`, `td_clear_dev_log`) are admin/dev-mode utilities — see `references/mcp-tools.md` for the full 36-tool reference with complete parameter schemas.
|
||||
|
||||
## Key Implementation Rules
|
||||
|
||||
@ -338,6 +339,10 @@ See `references/network-patterns.md` for complete build scripts + shader code.
|
||||
| `references/operator-tips.md` | Wireframe rendering, feedback TOP setup |
|
||||
| `references/geometry-comp.md` | Geometry COMP: instancing, POP vs SOP, morphing |
|
||||
| `references/audio-reactive.md` | Audio band extraction, beat detection, envelope following |
|
||||
| `references/animation.md` | LFOs, timers, keyframes, easing, expression-driven motion |
|
||||
| `references/midi-osc.md` | MIDI/OSC controllers, TouchOSC, multi-machine sync |
|
||||
| `references/particles.md` | POPs and legacy particleSOP — emission, forces, collisions |
|
||||
| `references/projection-mapping.md` | Multi-window output, corner pin, mesh warp, edge blending |
|
||||
| `scripts/setup.sh` | Automated setup script |
|
||||
|
||||
---
|
||||
|
||||
221
skills/creative/touchdesigner-mcp/references/animation.md
Normal file
221
skills/creative/touchdesigner-mcp/references/animation.md
Normal file
@ -0,0 +1,221 @@
|
||||
# Animation Reference
|
||||
|
||||
Patterns for time-based motion — keyframes, LFOs, timers, easing, expression-driven animation.
|
||||
|
||||
Always call `td_get_par_info` for the op type before setting params. Param names below reflect TD 2025.32 but verify if errors fire.
|
||||
|
||||
---
|
||||
|
||||
## Time Sources
|
||||
|
||||
TD has three time references — pick the right one.
|
||||
|
||||
| Expression | Behavior | Use for |
|
||||
|---|---|---|
|
||||
| `absTime.seconds` | Wall-clock seconds since TD started. Never resets. | Continuous motion, GLSL `uTime`, infinite loops |
|
||||
| `absTime.frame` | Wall-clock frame count. | Frame-accurate triggers |
|
||||
| `me.time.frame` | Local component frame count (resets on play/stop). | Per-COMP animation timeline |
|
||||
| `me.time.seconds` | Local component seconds. | Same, in seconds |
|
||||
|
||||
**Rule:** for shaders and continuous motion use `absTime.seconds`. For triggered/looping animations inside a COMP use `me.time.*`.
|
||||
|
||||
---
|
||||
|
||||
## LFO CHOP — Cyclic Motion
|
||||
|
||||
The simplest periodic driver. Fast, GPU-cheap, expression-friendly.
|
||||
|
||||
```python
|
||||
lfo = root.create(lfoCHOP, 'rot_driver')
|
||||
lfo.par.type = 'sin' # 'sin' | 'cos' | 'ramp' | 'square' | 'triangle' | 'pulse'
|
||||
lfo.par.frequency = 0.25 # cycles per second
|
||||
lfo.par.amplitude = 1.0
|
||||
lfo.par.offset = 0.0
|
||||
lfo.par.phase = 0.0 # 0-1, useful for offsetting parallel LFOs
|
||||
```
|
||||
|
||||
**Drive a parameter via export:**
|
||||
|
||||
```python
|
||||
op('/project1/geo1').par.rx.mode = ParMode.EXPRESSION
|
||||
op('/project1/geo1').par.rx.expr = "op('rot_driver')['chan1'] * 360"
|
||||
```
|
||||
|
||||
**Multiple synced LFOs (X/Y/Z rotation with phase offsets):**
|
||||
Create one LFO with three channels and phase-offset each, or use three LFOs and offset their `phase` params (0.0, 0.33, 0.66).
|
||||
|
||||
---
|
||||
|
||||
## Timer CHOP — Triggered Sequences
|
||||
|
||||
For run-once animations, beat-locked sequences, or stage-based logic.
|
||||
|
||||
```python
|
||||
timer = root.create(timerCHOP, 'fade_timer')
|
||||
timer.par.length = 4.0 # cycle length in seconds
|
||||
timer.par.cycle = False # run once vs. loop
|
||||
timer.par.outputseconds = True
|
||||
```
|
||||
|
||||
Output channels: `timer_fraction` (0→1 across the cycle), `running`, `done`, `cycles`.
|
||||
|
||||
**Start the timer:**
|
||||
```python
|
||||
timer.par.start.pulse()
|
||||
```
|
||||
|
||||
**Drive a fade:**
|
||||
```python
|
||||
op('/project1/level1').par.opacity.mode = ParMode.EXPRESSION
|
||||
op('/project1/level1').par.opacity.expr = "op('fade_timer')['timer_fraction']"
|
||||
```
|
||||
|
||||
**Easing on the timer fraction** — apply in the expression itself:
|
||||
|
||||
```python
|
||||
# Smoothstep: ease in/out
|
||||
expr = "smoothstep(0, 1, op('fade_timer')['timer_fraction'])"
|
||||
# Cubic ease-out: 1 - (1-t)^3
|
||||
expr = "1 - pow(1 - op('fade_timer')['timer_fraction'], 3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern CHOP — Custom Curves
|
||||
|
||||
For arbitrary waveforms (saw ramps, easing curves, custom envelopes).
|
||||
|
||||
```python
|
||||
pat = root.create(patternCHOP, 'envelope')
|
||||
pat.par.type = 'gaussian' # 'gaussian' | 'ramp' | 'square' | 'sin' | etc.
|
||||
pat.par.length = 60 # samples
|
||||
pat.par.cyclelength = 1.0 # seconds at TD framerate
|
||||
```
|
||||
|
||||
Combine with `lookupCHOP` to remap a 0-1 driver through a custom curve.
|
||||
|
||||
---
|
||||
|
||||
## Animation COMP — Keyframe-Based
|
||||
|
||||
For multi-keyframe motion graphics. Each animationCOMP holds channels with keyframes editable in the Animation Editor.
|
||||
|
||||
```python
|
||||
anim = root.create(animationCOMP, 'intro_anim')
|
||||
# By default has channels chan1..chanN; access via:
|
||||
# op('intro_anim').par.length, .par.play, .par.cue, etc.
|
||||
|
||||
# Drive a parameter from a channel
|
||||
op('/project1/text1').par.tx.mode = ParMode.EXPRESSION
|
||||
op('/project1/text1').par.tx.expr = "op('intro_anim/out1')['chan1']"
|
||||
```
|
||||
|
||||
**Keyframes are typically edited in the UI** (Animation Editor), but can be set via `keyframes` table internally. For programmatic keyframe creation, use `td_execute_python`:
|
||||
|
||||
```python
|
||||
# Get the channel CHOP inside an animationCOMP
|
||||
ch = op('/project1/intro_anim/chans')
|
||||
# Insert a key (advanced API — verify with td_get_par_info(op_type='animationCOMP'))
|
||||
ch.appendKey('chan1', frame=0, value=0.0, expression=None)
|
||||
ch.appendKey('chan1', frame=120, value=1.0)
|
||||
```
|
||||
|
||||
For most use cases, drive params with LFO/Timer/Pattern CHOPs instead — simpler and scriptable.
|
||||
|
||||
---
|
||||
|
||||
## Easing in Expressions
|
||||
|
||||
TD's expression evaluator supports Python math. Common easing forms:
|
||||
|
||||
```python
|
||||
# Linear
|
||||
"t"
|
||||
|
||||
# Smoothstep (classic ease-in-out)
|
||||
"smoothstep(0, 1, t)"
|
||||
|
||||
# Ease-out cubic
|
||||
"1 - pow(1 - t, 3)"
|
||||
|
||||
# Ease-in cubic
|
||||
"pow(t, 3)"
|
||||
|
||||
# Ease-in-out cubic
|
||||
"3*t*t - 2*t*t*t"
|
||||
|
||||
# Bounce (manual, simplified)
|
||||
"abs(sin(t * 6.28 * 3) * (1 - t))"
|
||||
```
|
||||
|
||||
Where `t` is `op('fade_timer')['timer_fraction']` or any 0-1 driver.
|
||||
|
||||
---
|
||||
|
||||
## Filter CHOP — Smoothing Existing Channels
|
||||
|
||||
Smooth out jittery values (e.g., audio analysis, sensor data) before driving visuals.
|
||||
|
||||
```python
|
||||
filt = root.create(filterCHOP, 'smooth')
|
||||
filt.par.filter = 'gaussian' # or 'lowpass'
|
||||
filt.par.width = 0.5 # smoothing window in seconds
|
||||
filt.inputConnectors[0].connect(op('raw_signal'))
|
||||
```
|
||||
|
||||
**WARNING:** Do NOT use Filter CHOP on AudioSpectrum output in timeslice mode — it expands the sample count and averages bins to near-zero. See `audio-reactive.md`.
|
||||
|
||||
---
|
||||
|
||||
## Lag CHOP — Asymmetric Attack/Release
|
||||
|
||||
Different speeds for rising vs. falling values. Standard for visualizing audio envelopes.
|
||||
|
||||
```python
|
||||
lag = root.create(lagCHOP, 'env_smooth')
|
||||
lag.par.lag1 = 0.02 # attack (rise time, seconds)
|
||||
lag.par.lag2 = 0.30 # release (fall time, seconds)
|
||||
lag.inputConnectors[0].connect(op('raw_envelope'))
|
||||
```
|
||||
|
||||
Fast attack, slow release = classic VU-meter feel.
|
||||
|
||||
---
|
||||
|
||||
## Per-Frame Driving via Script DAT
|
||||
|
||||
For complex per-frame logic that doesn't fit expressions, use a `executeDAT` (`onFrameStart` callback) or a `chopExecuteDAT`.
|
||||
|
||||
```python
|
||||
# In an executeDAT (frameStart):
|
||||
def onFrameStart(frame):
|
||||
t = absTime.seconds
|
||||
op('/project1/circle').par.tx = math.sin(t * 2.0) * 3.0
|
||||
op('/project1/circle').par.ty = math.cos(t * 2.0) * 3.0
|
||||
return
|
||||
```
|
||||
|
||||
Heavy logic should still be in CHOPs (CPU-cheap, deterministic). Reserve scripts for one-shots or non-realtime branching.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Frame rate dependency** — `me.time.frame` is in TD project frames (default 60). If your project rate changes, motion speed changes. Use `seconds` for rate-independent timing.
|
||||
2. **Cooking budget** — every CHOP that drives a parameter cooks every frame. Consolidate drivers (one big mathCHOP > many small ones).
|
||||
3. **Expression mode** — params default to `CONSTANT`. `par.X.expr = ...` is ignored unless `par.X.mode = ParMode.EXPRESSION`.
|
||||
4. **Animation editor edits** — keyframes set via UI live in the animationCOMP's internal keyframe table. They survive save/reopen. Programmatic keys via `appendKey()` work but verify the API with `td_get_docs(topic='animation')` first.
|
||||
5. **Looping animations** — for seamless loops, `length` must equal `cyclelength` and the start/end values must match. Otherwise expect a visible jump.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Simplest path |
|
||||
|---|---|
|
||||
| Continuous rotation | LFO CHOP `type='ramp'`, expr → `geo.par.rx` |
|
||||
| Fade in over 2s | Timer CHOP `length=2`, smoothstep expr → `level.par.opacity` |
|
||||
| Pulse on every beat | `triggerCHOP` from audio → drive scale via expression |
|
||||
| 3D Lissajous orbit | Two LFOs with different freq, drive `tx`/`ty`/`tz` |
|
||||
| Random jitter | `noiseCHOP` (low-freq) added to position |
|
||||
| Timed scene switch | Timer CHOP → switchTOP/CHOP `index` |
|
||||
211
skills/creative/touchdesigner-mcp/references/midi-osc.md
Normal file
211
skills/creative/touchdesigner-mcp/references/midi-osc.md
Normal file
@ -0,0 +1,211 @@
|
||||
# MIDI / OSC Reference
|
||||
|
||||
External controller input and output — MIDI hardware, TouchOSC mobile UIs, OSC routing across the network.
|
||||
|
||||
For audio-driven MIDI patterns (track triggers from spectrum analysis), see also `audio-reactive.md`.
|
||||
|
||||
---
|
||||
|
||||
## MIDI Input — Hardware Controllers
|
||||
|
||||
### Discovery
|
||||
|
||||
List connected MIDI devices first. Use a `midiinDAT` to enumerate:
|
||||
|
||||
```python
|
||||
mdat = root.create(midiinDAT, 'mid_devices')
|
||||
# Read available device names from the DAT after one cook
|
||||
```
|
||||
|
||||
Or via Python directly:
|
||||
|
||||
```python
|
||||
# In td_execute_python
|
||||
import td
|
||||
devices = [d for d in op.MIDI.devices] # verify with td_get_docs('midi')
|
||||
```
|
||||
|
||||
Verify the API with `td_get_docs(topic='midi')` since this varies between TD versions.
|
||||
|
||||
### MIDI In CHOP
|
||||
|
||||
Standard pattern:
|
||||
|
||||
```python
|
||||
midi_in = root.create(midiinCHOP, 'midi_in')
|
||||
midi_in.par.device = 0 # device index from discovery
|
||||
midi_in.par.activechan = True
|
||||
```
|
||||
|
||||
Output channels follow the convention `chCcN` and `chCnN`:
|
||||
- `ch1c74` — channel 1, CC 74
|
||||
- `ch1n60` — channel 1, note 60 (middle C) — value is velocity 0-127
|
||||
|
||||
**Map a CC to a parameter:**
|
||||
|
||||
```python
|
||||
op('/project1/bloom1').par.threshold.mode = ParMode.EXPRESSION
|
||||
op('/project1/bloom1').par.threshold.expr = "op('midi_in')['ch1c74'][0] / 127.0"
|
||||
```
|
||||
|
||||
**Map a note as a trigger:**
|
||||
|
||||
Notes in `midiinCHOP` output velocity while held, 0 when released. Use a `triggerCHOP` to convert a held note into pulses:
|
||||
|
||||
```python
|
||||
trig = root.create(triggerCHOP, 'note_trig')
|
||||
trig.par.threshold = 1
|
||||
trig.par.triggeron = 'increase'
|
||||
trig.inputConnectors[0].connect(op('midi_in'))
|
||||
# Filter to a single channel via a selectCHOP if desired
|
||||
```
|
||||
|
||||
### MIDI Learn Pattern
|
||||
|
||||
Build a reusable learn pattern when you don't know the controller's CC layout in advance:
|
||||
|
||||
1. Drop a `midiinCHOP` and `selectCHOP` after it.
|
||||
2. User wiggles the controller knob.
|
||||
3. Use `td_read_chop` on the midiinCHOP to identify which channel is non-zero — that's the active CC.
|
||||
4. Set the `selectCHOP.par.channames` to that channel name.
|
||||
5. Save the mapping to a `tableDAT` so it persists across sessions.
|
||||
|
||||
---
|
||||
|
||||
## MIDI Output
|
||||
|
||||
```python
|
||||
midi_out = root.create(midioutCHOP, 'midi_out')
|
||||
midi_out.par.device = 0
|
||||
midi_out.par.outputformat = 'continuous' # 'continuous' | 'event'
|
||||
|
||||
# Drive an output: send out a CC mapped from any 0-1 source
|
||||
src = root.create(constantCHOP, 'cc_src')
|
||||
src.par.name0 = 'ch1c20'
|
||||
src.par.value0 = 0.5
|
||||
midi_out.inputConnectors[0].connect(src)
|
||||
```
|
||||
|
||||
For note events specifically, use `event` mode and pulse the value with a `pulseCHOP` or `triggerCHOP`.
|
||||
|
||||
---
|
||||
|
||||
## OSC Input — Network Control
|
||||
|
||||
OSC is the more flexible cousin of MIDI. Used heavily for:
|
||||
- TouchOSC / Lemur mobile control surfaces
|
||||
- Show control systems (QLab, Watchout)
|
||||
- Inter-application sync (Ableton via Max for Live, Resolume, etc.)
|
||||
|
||||
### OSC In CHOP
|
||||
|
||||
```python
|
||||
osc_in = root.create(oscinCHOP, 'osc_in')
|
||||
osc_in.par.port = 7000 # listen on UDP 7000
|
||||
osc_in.par.localaddress = '' # empty = all interfaces
|
||||
osc_in.par.queued = False # immediate vs. queued processing
|
||||
```
|
||||
|
||||
Each incoming OSC address becomes a channel. `/scene/1/intensity` becomes a channel named `scene_1_intensity` (TD sanitizes slashes to underscores).
|
||||
|
||||
**Common gotcha:** TD only creates the channel after the FIRST message arrives at that address. Send a "hello" message from the controller during setup, or pre-declare channel names manually.
|
||||
|
||||
### OSC In DAT (for raw events)
|
||||
|
||||
Use a `oscinDAT` when you need full message access (multiple typed args, addresses with brackets/regex).
|
||||
|
||||
```python
|
||||
osc_dat = root.create(oscinDAT, 'osc_events')
|
||||
osc_dat.par.port = 7001
|
||||
# Each row: timestamp, address, type tags, args...
|
||||
```
|
||||
|
||||
Drive logic via a `datExecuteDAT` watching the `oscinDAT`:
|
||||
|
||||
```python
|
||||
def onTableChange(dat):
|
||||
last = dat[dat.numRows - 1, 'message']
|
||||
parsed = last.val.split()
|
||||
addr = parsed[0]
|
||||
args = parsed[1:]
|
||||
if addr == '/scene/trigger':
|
||||
op('/project1/scene_switcher').par.index = int(args[0])
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OSC Output — Sending to External Apps
|
||||
|
||||
```python
|
||||
osc_out = root.create(oscoutCHOP, 'osc_out')
|
||||
osc_out.par.netaddress = '127.0.0.1' # destination IP
|
||||
osc_out.par.port = 9000
|
||||
|
||||
# Channel names become OSC addresses
|
||||
src = root.create(constantCHOP, 'send')
|
||||
src.par.name0 = 'scene/intensity' # → /scene/intensity
|
||||
src.par.value0 = 0.7
|
||||
osc_out.inputConnectors[0].connect(src)
|
||||
```
|
||||
|
||||
**Channel-to-address mapping:** TD prepends `/` automatically. Use `/` in channel names to nest.
|
||||
|
||||
For one-shot string/typed messages, use `oscoutDAT` and call `.sendOSC(address, args)`:
|
||||
|
||||
```python
|
||||
op('osc_out_dat').sendOSC('/scene/trigger', [1, 'fade'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TouchOSC / Mobile UI Pattern
|
||||
|
||||
Common setup for live VJ control from a phone/tablet:
|
||||
|
||||
1. **Configure TouchOSC layout** — assign each control an OSC address like `/vj/master`, `/vj/scene/1`, etc.
|
||||
2. **Find your machine's LAN IP** — TouchOSC needs to point at it.
|
||||
3. **TD listens** on `oscinCHOP.par.port = 8000` (or whichever).
|
||||
4. **Map channels to params** via expressions:
|
||||
|
||||
```python
|
||||
op('/project1/master_level').par.opacity.mode = ParMode.EXPRESSION
|
||||
op('/project1/master_level').par.opacity.expr = "op('osc_in')['vj_master']"
|
||||
```
|
||||
|
||||
5. **Send feedback** to the controller via `oscoutCHOP` — useful for syncing state across multiple devices.
|
||||
|
||||
---
|
||||
|
||||
## Network / Multi-Machine
|
||||
|
||||
OSC over LAN works out-of-the-box. For multi-TD-instance sync (e.g., projection cluster):
|
||||
|
||||
- One TD acts as **master**, broadcasts `/sync/...` over OSC
|
||||
- Worker TDs run `oscinCHOP` listening on the same port
|
||||
- Use UDP **broadcast address** (e.g., `192.168.1.255`) on the master's `oscoutCHOP.par.netaddress` to hit all peers
|
||||
|
||||
For reliability over WAN, use `webserverDAT` or `websocketDAT` with an external relay instead — UDP loss is invisible.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **MIDI device indexing** — device `0` is whichever device TD enumerated first. Reorder may shift it. Pin by name when possible.
|
||||
2. **OSC channel names** — TD doesn't create a channel until the first message lands. New channels invalidate cooked dependents on first arrival, causing a one-frame stutter.
|
||||
3. **OSC queued mode** — `par.queued = True` defers processing to a single per-frame batch. Lower latency but messages arriving same frame collapse to the last value. Off for triggers, on for continuous knobs.
|
||||
4. **MIDI clock vs. transport** — `midiinCHOP` reports clock if available. Use `midisyncCHOP` (if your TD version exposes it) or compute BPM from clock pulses (24 per quarter note).
|
||||
5. **Latency** — wired MIDI is ~1-3ms. WiFi OSC is 10-30ms with jitter. Use wired for tight beat-locked work.
|
||||
6. **Port conflicts** — only one process can bind a UDP port on most OS. If `oscinCHOP` shows no traffic, check that another app (Max, Ableton, etc.) isn't already listening on that port.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Op chain |
|
||||
|---|---|
|
||||
| Knob → bloom intensity | `midiinCHOP` → expression on `bloom.par.threshold` |
|
||||
| Note → scene change | `midiinCHOP` → `triggerCHOP` → `selectCHOP` → drive `switchTOP.par.index` |
|
||||
| Phone slider → master fader | TouchOSC `/master` → `oscinCHOP` → expression on output `level.par.opacity` |
|
||||
| TD → Resolume scene trigger | `oscoutCHOP` channel `composition/layers/1/clips/1/connect` → Resolume listening on 7000 |
|
||||
| Multi-projector sync | Master TD `oscoutCHOP` broadcast → workers `oscinCHOP` |
|
||||
245
skills/creative/touchdesigner-mcp/references/particles.md
Normal file
245
skills/creative/touchdesigner-mcp/references/particles.md
Normal file
@ -0,0 +1,245 @@
|
||||
# Particles Reference
|
||||
|
||||
Particle systems in TouchDesigner — modern POPs (Particle Operators) and the legacy particleSOP path.
|
||||
|
||||
For instancing static geometry (without per-instance lifetime/velocity), see `geometry-comp.md`. For GLSL-driven feedback simulations (no particle abstraction), see `operator-tips.md` (Feedback TOP section).
|
||||
|
||||
Always call `td_get_par_info` for the op type before setting params. Param names below reflect TD 2025.32 — verify before relying on them.
|
||||
|
||||
---
|
||||
|
||||
## Two Paths: POPs vs. SOPs
|
||||
|
||||
| | **POP family** (modern) | **particleSOP** (legacy) |
|
||||
|---|---|---|
|
||||
| GPU? | Yes (compute) | No (CPU) |
|
||||
| Particle count | 100k+ comfortably | ~5k before slowdown |
|
||||
| API style | Source / Force / Solver / Render chain | Single op with many params |
|
||||
| Use for | New projects, anything intensive | Quick demos, low counts, TD < 2023 |
|
||||
|
||||
**Default to POPs.** Only fall back to particleSOP if a POP variant of an op you need doesn't exist.
|
||||
|
||||
---
|
||||
|
||||
## POP Pipeline Overview
|
||||
|
||||
A POP system is a chain of operators inside a `geometryCOMP`:
|
||||
|
||||
```
|
||||
popSourceTOP / popSourceSOP ← spawn new particles
|
||||
↓
|
||||
popForceTOP (gravity, wind, etc.)
|
||||
↓
|
||||
popForceTOP (attractor, vortex, ...)
|
||||
↓
|
||||
popDeleteTOP (lifetime, bounds)
|
||||
↓
|
||||
popSolverTOP ← integrates velocity, updates positions
|
||||
↓
|
||||
[render via geometryCOMP / glslMAT instancing]
|
||||
```
|
||||
|
||||
POP buffers carry standard channels: `P` (position), `v` (velocity), `life`, `id`, `Cd` (color), plus any custom channels you add.
|
||||
|
||||
---
|
||||
|
||||
## Minimal POP Setup
|
||||
|
||||
```python
|
||||
# Create a geometry COMP to hold the POP network
|
||||
geo = root.create(geometryCOMP, 'particles_geo')
|
||||
|
||||
# 1. Source — emit particles from a point
|
||||
src = geo.create(popSourceTOP, 'src')
|
||||
src.par.birthrate = 500 # per second
|
||||
src.par.life = 4.0 # seconds
|
||||
|
||||
# 2. Gravity force
|
||||
grav = geo.create(popForceTOP, 'gravity')
|
||||
grav.par.forcetype = 'gravity'
|
||||
grav.par.fy = -9.8
|
||||
|
||||
# 3. Lifetime cleanup
|
||||
delp = geo.create(popDeleteTOP, 'cull')
|
||||
delp.par.condition = 'lifeleq' # delete when life <= 0
|
||||
delp.par.value = 0
|
||||
|
||||
# 4. Solver
|
||||
solv = geo.create(popSolverTOP, 'solver')
|
||||
solv.par.timestep = 'frame'
|
||||
|
||||
# Wire: source → force → delete → solver
|
||||
src.outputConnectors[0].connect(grav.inputConnectors[0])
|
||||
grav.outputConnectors[0].connect(delp.inputConnectors[0])
|
||||
delp.outputConnectors[0].connect(solv.inputConnectors[0])
|
||||
```
|
||||
|
||||
The `popSolverTOP` output IS the live particle buffer. Render it via `glslMAT` instancing on a small SOP (sphere, point) as the "shape" of each particle.
|
||||
|
||||
---
|
||||
|
||||
## Common Forces
|
||||
|
||||
| Force type | Effect | Common params |
|
||||
|---|---|---|
|
||||
| `gravity` | Constant directional pull | `fx`, `fy`, `fz` |
|
||||
| `wind` | Constant velocity addition | `wx`, `wy`, `wz` |
|
||||
| `drag` | Velocity damping over time | `dragstrength` |
|
||||
| `noise` | Curl-noise turbulence | `noiseamp`, `noisefreq`, `noiseseed` |
|
||||
| `attractor` | Pull toward a point | `position`, `strength`, `falloff` |
|
||||
| `vortex` | Swirl around an axis | `axis`, `strength` |
|
||||
| `point` (custom) | GLSL-evaluated arbitrary force | via `popforceadvancedTOP` |
|
||||
|
||||
Stack multiple `popForceTOP`s in series — each modifies velocity additively.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Patterns
|
||||
|
||||
### Continuous emission (e.g. smoke plume)
|
||||
|
||||
```python
|
||||
src.par.birthrate = 800
|
||||
src.par.life = 6.0 # variance via 'lifevariance'
|
||||
src.par.lifevariance = 1.5
|
||||
```
|
||||
|
||||
### Burst emission (e.g. explosion)
|
||||
|
||||
```python
|
||||
src.par.birthrate = 0 # no continuous emission
|
||||
src.par.burst.pulse() # one burst on demand (verify param name)
|
||||
src.par.burstcount = 5000
|
||||
src.par.life = 1.5
|
||||
```
|
||||
|
||||
### Beat-triggered burst
|
||||
|
||||
Wire a `triggerCHOP` (from audio or MIDI) to pulse the burst:
|
||||
|
||||
```python
|
||||
op('/project1/audio_kick_trigger').outputConnectors[0].connect(...)
|
||||
# Then via a chopExecuteDAT, on each kick:
|
||||
def offToOn(channel, sampleIndex, val, prev):
|
||||
op('/project1/particles_geo/src').par.burst.pulse()
|
||||
return
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering Particles
|
||||
|
||||
### Point Sprites (simplest)
|
||||
|
||||
```python
|
||||
# Inside the geometryCOMP, render the solver output directly
|
||||
# The geo's first SOP child becomes the geometry
|
||||
# But for POPs, we typically render via glslMAT on a small "shape"
|
||||
|
||||
# Simple billboard sphere per particle:
|
||||
shape = geo.create(sphereSOP, 'shape')
|
||||
shape.par.rad = 0.05
|
||||
shape.par.rows = 6; shape.par.cols = 6 # low-poly to keep it fast
|
||||
|
||||
# Material that uses POP buffer for instancing
|
||||
mat = root.create(glslMAT, 'particle_mat')
|
||||
# Configure mat.par.instancingTOP = solver output (verify param name)
|
||||
```
|
||||
|
||||
The exact instancing setup varies by TD version — call `td_get_hints(topic='popInstancing')` (or `popRender` / `instancing` — try a few).
|
||||
|
||||
### GPU Sprites via glslcopyPOP
|
||||
|
||||
For dense smoke/fire-like effects, use a `glslcopyPOP` that writes per-particle color/size from a compute shader, then render as point sprites with additive blending in a `renderTOP`.
|
||||
|
||||
---
|
||||
|
||||
## Collisions
|
||||
|
||||
```python
|
||||
# Collision detection against an SOP
|
||||
coll = geo.create(popCollideTOP, 'ground_coll')
|
||||
coll.par.collidewithsop = '/project1/ground_geo' # path to colliding SOP
|
||||
coll.par.bounce = 0.3
|
||||
coll.par.friction = 0.1
|
||||
# Insert between force and solver
|
||||
```
|
||||
|
||||
For plane/box collisions only, use `popPlaneCollideTOP` (cheaper).
|
||||
|
||||
---
|
||||
|
||||
## Custom Per-Particle Data
|
||||
|
||||
Add a custom channel via `popAttribCreateTOP` (or by writing through `glslcopyPOP`):
|
||||
|
||||
```python
|
||||
# Add a "phase" attribute initialized random per-particle, used in render shader
|
||||
attr = geo.create(popAttribCreateTOP, 'add_phase')
|
||||
attr.par.attribname = 'phase'
|
||||
attr.par.value0 = 'rand(@id)' # expression in TD's POP attribute language
|
||||
```
|
||||
|
||||
Then in the render shader, `texture(sTDPOPInputs[0].phase, ...)` (or whichever sampler convention your TD version uses — verify with `td_get_docs(topic='pops')`).
|
||||
|
||||
---
|
||||
|
||||
## Legacy particleSOP (Use Sparingly)
|
||||
|
||||
For quick demos or low-count systems:
|
||||
|
||||
```python
|
||||
# Inside a geo
|
||||
psrc = geo.create(addSOP, 'point_src') # source: a single point
|
||||
psrc.par.points = '0 0 0'
|
||||
|
||||
part = geo.create(particleSOP, 'particles')
|
||||
part.par.life = 3.0
|
||||
part.par.birthrate = 100
|
||||
part.par.gravityy = -9.8
|
||||
part.par.windx = 0.5
|
||||
part.inputConnectors[0].connect(psrc)
|
||||
```
|
||||
|
||||
CPU-bound. Beyond ~5,000 active particles you'll see frame drops.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Particles don't appear** — usually a render-side issue. Check via `td_get_screenshot` on the solver output (renders the buffer as a TOP-like view in newer TD). Then check the `geometryCOMP`'s render path.
|
||||
2. **Burst won't fire** — verify the `burst` param is a pulse, not a toggle. Pulses must use `.pulse()`, not `= True`.
|
||||
3. **Particles teleport on first frame** — uninitialized velocity. Set `popSourceTOP.par.initialvelocityX/Y/Z` or zero them explicitly.
|
||||
4. **Gravity feels wrong** — TD's "1 unit" depends on your scene scale. Start with `fy = -1.0` and scale up rather than using real-world 9.8.
|
||||
5. **High birthrate = stuttering** — birthrate is per-second, not per-frame. At 60fps, `birthrate = 6000` is 100/frame which is fine; `birthrate = 600000` will tank.
|
||||
6. **POP solver order matters** — forces apply in the order they appear in the chain. Putting gravity AFTER drag dampens gravity itself; usually not what you want.
|
||||
7. **Instancing param name varies** — `mat.par.instancingTOP` vs. `mat.par.instanceop` vs. `mat.par.instances` differs across TD versions. Always check `td_get_par_info(op_type='glslMAT')`.
|
||||
8. **Cooking dependency loops** — POP solvers create implicit time-loops. The "cook dependency loop" warning is expected and harmless for POPs.
|
||||
9. **CHOP-driven force values** — when a force param is expression-bound to a CHOP (e.g., audio-reactive gravity), make sure the CHOP cooks before the solver. If not, force lags by one frame.
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Particle count | Setup | Frame budget @ 60fps |
|
||||
|---|---|---|
|
||||
| < 1k | particleSOP fine | trivial |
|
||||
| 1k - 10k | POPs, simple forces | ~2-5ms |
|
||||
| 10k - 100k | POPs, GPU-only forces | ~5-15ms |
|
||||
| 100k+ | `glslcopyPOP`, custom compute | ~10-25ms |
|
||||
| 1M+ | Custom GPU buffer, no POP framework | depends on shader |
|
||||
|
||||
Use `td_get_perf` to find which op in the POP chain is the bottleneck.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Pipeline |
|
||||
|---|---|
|
||||
| Smoke plume | `popSourceTOP` (point) → gravity + wind + noise → `popDeleteTOP` (life) → solver → glslMAT instancing |
|
||||
| Beat-triggered burst | `triggerCHOP` (audio) → chopExecuteDAT pulses `popSourceTOP.par.burst` |
|
||||
| Fireworks shell | Burst at point → drag + gravity → secondary burst on lifetime threshold |
|
||||
| Snow/rain | Continuous emission across XZ plane (high y), gravity + small wind, infinite life box-deleted |
|
||||
| Sparks | Burst, very short life (0.3s), bright additive render, motion blur via feedback |
|
||||
| Audio particles | Birthrate driven by audio envelope, color driven by frequency band |
|
||||
@ -0,0 +1,211 @@
|
||||
# Projection Mapping Reference
|
||||
|
||||
Multi-window output, surface mapping, edge blending, and projector calibration patterns for installation/event work.
|
||||
|
||||
For HUD layouts and on-screen panel grids, see `layout-compositor.md`. For wireframe/test-pattern generation, see `operator-tips.md`.
|
||||
|
||||
---
|
||||
|
||||
## Window COMP — Output to a Display
|
||||
|
||||
The `windowCOMP` is how TD pushes pixels to a real display.
|
||||
|
||||
```python
|
||||
win = root.create(windowCOMP, 'output_window')
|
||||
win.par.winop = '/project1/final_out' # path to the TOP being displayed
|
||||
win.par.winw = 1920
|
||||
win.par.winh = 1080
|
||||
win.par.winoffsetx = 0 # screen-space offset
|
||||
win.par.winoffsety = 0
|
||||
win.par.borders = False # no chrome
|
||||
win.par.alwaysontop = True
|
||||
win.par.cursor = False # hide cursor in fullscreen
|
||||
win.par.justify = 'fillaspect' # 'fill' | 'fitaspect' | 'fillaspect' | 'native'
|
||||
win.par.winopen.pulse() # OPEN the window
|
||||
```
|
||||
|
||||
To target a specific physical display, set `par.location`:
|
||||
|
||||
```python
|
||||
win.par.location = 'secondary' # 'primary' | 'secondary' | 'monitor1' | 'monitor2' | ...
|
||||
```
|
||||
|
||||
Or set absolute coordinates using `winoffsetx/y` matched to your OS display layout.
|
||||
|
||||
**Always pulse `winopen` — setting params alone doesn't open the window.**
|
||||
|
||||
---
|
||||
|
||||
## Multi-Window Output
|
||||
|
||||
For multi-projector or multi-display setups, create one `windowCOMP` per output, each pointing at a different TOP.
|
||||
|
||||
```python
|
||||
for i, screen_top in enumerate(['out_left', 'out_center', 'out_right']):
|
||||
w = root.create(windowCOMP, f'win_{i}')
|
||||
w.par.winop = f'/project1/{screen_top}'
|
||||
w.par.winw = 1920; w.par.winh = 1080
|
||||
w.par.winoffsetx = i * 1920
|
||||
w.par.winoffsety = 0
|
||||
w.par.borders = False
|
||||
w.par.alwaysontop = True
|
||||
w.par.cursor = False
|
||||
w.par.winopen.pulse()
|
||||
```
|
||||
|
||||
For ultra-wide single-output spans, use ONE windowCOMP at e.g. 5760×1080 spanning three projectors via the GPU's mosaic/spanning mode (Nvidia Mosaic, AMD Eyefinity), then split content via `cropTOP` per screen inside TD.
|
||||
|
||||
---
|
||||
|
||||
## 4-Point Corner Pin (Quad Warp)
|
||||
|
||||
The simplest projection mapping primitive — warping a rectangle onto a quadrilateral.
|
||||
|
||||
```python
|
||||
# Source content
|
||||
src = op('/project1/scene_out')
|
||||
|
||||
# Manual: cornerPinTOP (TD has this built-in)
|
||||
cp = root.create(cornerPinTOP, 'corner_pin')
|
||||
cp.par.tlx = 0.05; cp.par.tly = 0.10 # top-left (normalized 0-1)
|
||||
cp.par.trx = 0.95; cp.par.try = 0.08 # top-right
|
||||
cp.par.brx = 0.93; cp.par.bry = 0.92 # bottom-right
|
||||
cp.par.blx = 0.07; cp.par.bly = 0.94 # bottom-left
|
||||
cp.inputConnectors[0].connect(src)
|
||||
```
|
||||
|
||||
Alternative: use a `geometryCOMP` with a `gridSOP` and bend the verts in vertex GLSL. More flexible (curved surfaces) but more setup.
|
||||
|
||||
Verify TD 2025.32 param names with `td_get_par_info(op_type='cornerPinTOP')`.
|
||||
|
||||
---
|
||||
|
||||
## Bezier / Mesh Warp (Curved Surfaces)
|
||||
|
||||
For non-flat surfaces (domes, columns, curved walls), use a subdivided mesh and per-vertex displacement.
|
||||
|
||||
### Pattern: Grid Mesh + GLSL Displacement
|
||||
|
||||
```python
|
||||
# Subdivided grid in a geo
|
||||
geo = root.create(geometryCOMP, 'warp_geo')
|
||||
grid = geo.create(gridSOP, 'warp_grid')
|
||||
grid.par.rows = 32 # higher = smoother curve
|
||||
grid.par.cols = 32
|
||||
grid.par.sizex = 2; grid.par.sizey = 2
|
||||
|
||||
# Texture the source onto it
|
||||
mat = root.create(constMAT, 'warp_mat') # use constMAT for unlit projection
|
||||
mat.par.maptop = '/project1/scene_out' # source TOP
|
||||
|
||||
geo.par.material = mat.path
|
||||
|
||||
# Render to a TOP that goes to the projector window
|
||||
cam = root.create(cameraCOMP, 'cam_proj')
|
||||
cam.par.tz = 4
|
||||
|
||||
render = root.create(renderTOP, 'projection_out')
|
||||
render.par.camera = cam.path
|
||||
render.par.geometry = geo.path
|
||||
render.par.outputresolution = 'custom'
|
||||
render.par.resolutionw = 1920; render.par.resolutionh = 1080
|
||||
```
|
||||
|
||||
For per-vertex offsets, write a vertex GLSL on the constMAT (or use `glslMAT`) and read displacement values from a CHOP via uniform.
|
||||
|
||||
Calibration is iterative: render a checkerboard from `scene_out`, project it, photograph the projection, manually nudge corner/grid points until aligned.
|
||||
|
||||
---
|
||||
|
||||
## Edge Blending (Multi-Projector Overlap)
|
||||
|
||||
When two projectors overlap, the overlap region is twice as bright. Blend by ramping each projector's edge alpha to 0 across the overlap zone.
|
||||
|
||||
### GLSL Edge Blend Shader
|
||||
|
||||
Per-projector output pass that fades the inside edge to black:
|
||||
|
||||
```glsl
|
||||
// edge_blend_pixel.glsl
|
||||
out vec4 fragColor;
|
||||
uniform float uBlendLeft; // overlap width on left edge (0-0.5, 0=no blend)
|
||||
uniform float uBlendRight;
|
||||
uniform float uGamma; // typically 2.2 — perceptual ramp
|
||||
|
||||
void main() {
|
||||
vec2 uv = vUV.st;
|
||||
vec4 col = texture(sTD2DInputs[0], uv);
|
||||
|
||||
float aL = (uBlendLeft > 0.0) ? smoothstep(0.0, uBlendLeft, uv.x) : 1.0;
|
||||
float aR = (uBlendRight > 0.0) ? smoothstep(0.0, uBlendRight, 1.0 - uv.x) : 1.0;
|
||||
float a = pow(aL * aR, uGamma);
|
||||
|
||||
fragColor = TDOutputSwizzle(vec4(col.rgb * a, 1.0));
|
||||
}
|
||||
```
|
||||
|
||||
Apply this to each overlap-touching projector's output. Tune `uBlendLeft` / `uBlendRight` to match your physical overlap.
|
||||
|
||||
For top/bottom blends or cylindrical setups, extend the shader with `uBlendTop` / `uBlendBottom`.
|
||||
|
||||
---
|
||||
|
||||
## Calibration Patterns
|
||||
|
||||
Useful test patterns for aligning projectors. Build a `switchTOP` selecting one of these, route to all projector windows during setup.
|
||||
|
||||
```python
|
||||
# Solid white — for brightness/uniformity check
|
||||
white = root.create(constantTOP, 'cal_white')
|
||||
white.par.colorr = 1.0; white.par.colorg = 1.0; white.par.colorb = 1.0
|
||||
|
||||
# Centered crosshair — for keystone alignment
|
||||
gridcross = root.create(textTOP, 'cal_cross')
|
||||
gridcross.par.text = '+'
|
||||
gridcross.par.fontsizex = 200
|
||||
|
||||
# Fine grid — for warp/mesh alignment (use rampTOP + math + threshold, or build via GLSL)
|
||||
# Color bars for projector color calibration
|
||||
bars = root.create(rampTOP, 'cal_bars')
|
||||
bars.par.type = 'horizontal'
|
||||
```
|
||||
|
||||
Or use the bundled `testpatternTOP` if your TD version includes it.
|
||||
|
||||
---
|
||||
|
||||
## Projection Audit Workflow
|
||||
|
||||
When debugging a multi-screen setup:
|
||||
|
||||
1. Render a unique color and label per output (`textTOP` saying "LEFT", "CENTER", "RIGHT").
|
||||
2. Check that each window is sourcing the correct path: `td_get_operator_info(path='/project1/win_0')`.
|
||||
3. Verify display assignment: walk to each projector and confirm visually.
|
||||
4. Check resolution: physical projector native res vs. TD output res — mismatches cause scaling artifacts.
|
||||
5. Cook flag: `td_get_perf` — if a window's source TOP isn't cooking, the projector shows last frame frozen.
|
||||
|
||||
---
|
||||
|
||||
## Pitfalls
|
||||
|
||||
1. **Window won't open** — you forgot `winopen.pulse()`. Setting params alone doesn't open it.
|
||||
2. **Wrong display** — `par.location='secondary'` depends on OS display order. Set `winoffsetx/y` to absolute coords as a more reliable override.
|
||||
3. **Cursor visible** — set `par.cursor = False` BEFORE opening, or close+reopen.
|
||||
4. **Black projection** — usually a cooking issue. Verify `final_out` TOP is cooking via `td_get_perf`. Check `td_get_errors` recursively from `/`.
|
||||
5. **Tearing / vsync** — `windowCOMP` honors `par.vsync`. For projection always set `vsync='vsync'` (default). Tearing means GPU is over-budget — reduce render resolution.
|
||||
6. **Aspect mismatch** — projector native is often 1920×1200 (16:10) not 1080. Use `justify='fitaspect'` or render at native projector res.
|
||||
7. **Non-Commercial license** — caps total resolution at 1280×1280. For real installation work you need Commercial. Pro license adds 4K+.
|
||||
8. **Multiple monitors on macOS** — `windowCOMP` honors macOS Spaces. Disable Spaces or pin TD to a specific display in System Settings before showtime.
|
||||
|
||||
---
|
||||
|
||||
## Quick Recipes
|
||||
|
||||
| Goal | Approach |
|
||||
|---|---|
|
||||
| Single fullscreen output | One `windowCOMP`, `justify='fillaspect'`, `winopen.pulse()` |
|
||||
| 3-projector wide span | 3 `windowCOMP` + per-output `cropTOP` from one wide source |
|
||||
| Single quad surface | `cornerPinTOP` → `windowCOMP` |
|
||||
| Curved/dome | Subdivided gridSOP with vertex GLSL → `renderTOP` → `windowCOMP` |
|
||||
| Edge blend overlap | GLSL fade shader per projector → `windowCOMP` |
|
||||
| Calibration mode | `switchTOP` between scene and test patterns, hot-key triggered |
|
||||
Loading…
Reference in New Issue
Block a user