
Cinema4d Mcp
- 57 installs
- 7 repo stars
- Updated July 30, 2026
- vladmdgolam/agent-skills
Helps with ai & agent building tasks.
About
cinema4d-mcp is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- cinema4d-mcp
- AI & Agent Building
- AI-coding skill
Cinema4d Mcp by the numbers
- 57 all-time installs (skills.sh)
- +2 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #6,669 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vladmdgolam/agent-skills --skill cinema4d-mcpAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 57 |
|---|---|
| repo stars | ★ 7 |
| Last updated | July 30, 2026 |
| Repository | vladmdgolam/agent-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Cinema 4D MCP
Source of Truth
This skill targets Vlad's fork of Cinema 4D MCP: vladmdgolam/cinema4d-mcp.
Relevant fork additions:
inspect_redshift_materials- read-only Redshift inspector for assignments, preview-derived colors, readable description/container fields, and best-effort graph probing. Note: this tool may skip RS materials asnot_redshift_like(type 5703) — useexecute_python_scriptwith themaxonAPI for full RS node graph extraction instead (see Redshift Material Extraction section).- Duplicate-safe material targeting - the inspector accepts
material_indexand returns stable scene indices plus duplicate-name hints when names collide. - Redshift GraphView fallback - when node-space access fails but
import redshiftworks, the inspector falls back toredshift.GetRSMaterialNodeMaster(...)and reports GraphView nodes plus resolved connections. - The loaded C4D plugin may lag behind the repo copy. After plugin edits, restart Cinema 4D before trusting new tool behavior.
Tool Selection
Use structured MCP tools (get_scene_info, list_objects, add_primitive, etc.) for simple operations.
Use `execute_python_script` as the primary path for non-trivial extraction. It avoids wrapper/schema mismatches, gives full c4d + maxon API access, and allows proper frame stepping control. This is especially important for Redshift material extraction — the maxon node-space API gives full access to RS node graphs, which other tools may miss.
Use `inspect_redshift_materials` as a quick overview of material assignments and preview colors, but be aware it may skip RS materials as not_redshift_like if they use type 5703 wrappers. For full RS node graph data, use execute_python_script with the maxon API pattern documented in the Redshift section.
Health Check (Always First)
1. get_scene_info - verify connection 2. execute_python_script with print("ok") - verify Python works 3. If both work, extraction is possible even when other tools are broken
Critical Rules
1. World vs Local Coordinates
GeGetMoData() returns cloner-local positions. Always apply global matrix:
mg = cloner.GetMg()
world_pos = mg * m.off # LOCAL -> WORLDMissing this shifts everything by the cloner's global offset.
2. Visibility Constants Are Swapped
MODE_OFF = 1(not 0!)MODE_ON = 0(not 1!)MODE_UNDEF = 2(default/inherit)
Always use c4d.MODE_OFF / c4d.MODE_ON, never raw integers.
3. Sequential Frame Stepping
MoGraph effectors accumulate state. Iterate 0->N sequentially:
for frame in range(start, end + 1):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
# NOW read dataNever jump to arbitrary frames. Never skip ExecutePasses.
4. Split Heavy Bakes
MCP scripts timeout on large frame ranges (~20-30s default, some forks 60s). Bake in chunks (e.g., frames 0-200, then 200-400), combine afterward. Log progress with print().
5. Iterative Traversal Only
Use stack-based traversal. Recursive traversal hits Python recursion limits:
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None6. API Version Compatibility
Constants differ between C4D versions. Use defensive checks:
if hasattr(c4d, "SCENEFILTER_ANIMATION"):
...7. Check Render Visibility
Objects can be disabled via traffic lights (GetRenderMode()), RS Object tags, parent hierarchy inheritance, or Takes system.
Complete Animation Bake Workflow
This is the authoritative end-to-end procedure. Follow it in order — each step gates the next.
Step 1: Health Check
# Tool call: get_scene_info
# Then:
import c4d
print(doc.GetDocumentName(), doc.GetFps(), doc.GetMaxTime().GetFrame(doc.GetFps()))Confirm: scene name resolves, fps is correct (typically 24/25/30), max frame is the expected end frame.
Step 2: Discover Animation Tracks
Before baking, confirm the cloner actually has animated effectors:
import c4d
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None
fps = doc.GetFps()
cloner = find_obj("MyClonerName") # replace with actual name
# Check direct tracks on cloner
for t in cloner.GetCTracks():
did = t.GetDescriptionID()
ids = [int(did[i].id) for i in range(did.GetDepth())]
curve = t.GetCurve()
key_count = curve.GetKeyCount() if curve else 0
print(f"Track IDs: {ids}, keys: {key_count}")
# Check effector children for their own tracks
child = cloner.GetDown()
while child:
for t in child.GetCTracks():
did = t.GetDescriptionID()
ids = [int(did[i].id) for i in range(did.GetDepth())]
print(f" Effector '{child.GetName()}' track IDs: {ids}")
child = child.GetNext()If no tracks appear, the animation may be driven by fields or expressions — proceed to bake anyway; ExecutePasses will resolve those.
Step 3: Bake MoGraph (Sequential Frame Stepping)
import c4d
from c4d.modules import mograph as mo
import json
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None
def vec(v):
return [float(v.x), float(v.y), float(v.z)]
fps = doc.GetFps()
start = 0
end = int(doc.GetMaxTime().GetFrame(fps))
cloner = find_obj("MyClonerName")
mg = cloner.GetMg() # global matrix for LOCAL->WORLD
frames_data = {}
for frame in range(start, end + 1):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
md = mo.GeGetMoData(cloner)
if md is None:
print(f"Frame {frame}: no MoData")
continue
matrices = md.GetArray(c4d.MODATA_MATRIX)
clone_indices = md.GetArray(c4d.MODATA_CLONE)
frame_clones = []
for i, m in enumerate(matrices):
world_pos = mg * m.off
scale = (m.v1.GetLength() + m.v2.GetLength() + m.v3.GetLength()) / 3.0
frame_clones.append({
"index": i,
"position": vec(world_pos),
"scale": float(scale),
"clone_index": float(clone_indices[i]) if clone_indices else None
})
frames_data[frame] = frame_clones
if frame % 50 == 0:
print(f"Baked frame {frame}/{end}")
print(f"Done. Total frames baked: {len(frames_data)}")Step 4: Extract Keyframes (Optional — for sparse data)
If you need keyframe-only data rather than every-frame bake:
import c4d
fps = doc.GetFps()
obj = find_obj("MyObject")
keyframe_data = {}
for t in obj.GetCTracks():
did = t.GetDescriptionID()
ids = [int(did[i].id) for i in range(did.GetDepth())]
curve = t.GetCurve()
if not curve:
continue
keys = []
for k in range(curve.GetKeyCount()):
key = curve.GetKey(k)
keys.append({
"frame": key.GetTime().GetFrame(fps),
"value": float(key.GetValue())
})
keyframe_data[str(ids)] = keys
print(json.dumps(keyframe_data))Step 5: Export JSON
import json
output = {
"scene": doc.GetDocumentName(),
"fps": fps,
"start_frame": start,
"end_frame": end,
"clone_count": len(frames_data.get(start, [])),
"frames": frames_data
}
import tempfile, os
export_path = os.path.join(tempfile.gettempdir(), "mograph_export.json")
with open(export_path, "w") as f:
json.dump(output, f)
print(f"Exported {len(frames_data)} frames to {export_path}")Step 6: Validate
Run this after export to catch silent errors before handing data downstream:
import json, math
with open(export_path) as f:
data = json.load(f)
fps = data["fps"]
start = data["start_frame"]
end = data["end_frame"]
expected_frames = end - start + 1
actual_frames = len(data["frames"])
errors = []
if actual_frames != expected_frames:
errors.append(f"Frame count mismatch: expected {expected_frames}, got {actual_frames}")
nan_count = 0
for frame_key, clones in data["frames"].items():
for clone in clones:
for coord in clone["position"]:
if math.isnan(coord) or math.isinf(coord):
nan_count += 1
if clone.get("clone_index") is not None:
if not (0.0 <= clone["clone_index"] <= 1.0):
errors.append(f"Frame {frame_key} clone {clone['index']}: clone_index out of range: {clone['clone_index']}")
if nan_count > 0:
errors.append(f"NaN/Inf found in {nan_count} position coordinates")
if errors:
for e in errors:
print("ERROR:", e)
else:
print("Validation passed.")
print(f" Frames: {actual_frames}, Clones per frame: {data['clone_count']}")Validation Checklist
Before Baking
- [ ] Frame range set in scene (
doc.GetMaxTime()returns expected end frame) - [ ] All effectors active (check visibility traffic lights and Tags)
- [ ] No interfering Takes (
doc.GetTakeData().GetCurrentTake()is the correct take) - [ ] Test on small range (frames 0-10) first — confirm clone count and positions look right before running full bake
After Baking
- [ ] Total frame count equals
end - start + 1(no off-by-one, no gaps) - [ ] No NaN or Inf values in position coordinates
- [ ] Clone indices are in range 0.0–1.0 (if using
MODATA_CLONE) - [ ] World positions make sense — spot-check frame 0 and last frame against viewport
MoGraph Extraction Pattern
import c4d
from c4d.modules import mograph as mo
def vec(v):
return [float(v.x), float(v.y), float(v.z)]
cloner = find_obj("ClonerName")
mg = cloner.GetMg()
for frame in range(start, end + 1, step):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
md = mo.GeGetMoData(cloner)
matrices = md.GetArray(c4d.MODATA_MATRIX)
for i, m in enumerate(matrices):
world_pos = mg * m.off
scale = (m.v1.GetLength() + m.v2.GetLength() + m.v3.GetLength()) / 3.0Animation Track Discovery
for t in obj.GetCTracks():
did = t.GetDescriptionID()
ids = [int(did[i].id) for i in range(did.GetDepth())]
curve = t.GetCurve()
keys = []
if curve:
for k in range(curve.GetKeyCount()):
key = curve.GetKey(k)
keys.append({"frame": key.GetTime().GetFrame(fps), "value": float(key.GetValue())})Cloner Mode Constants
c4d.ID_MG_MOTIONGENERATOR_MODE: 0=Grid, 1=Linear, 2=Radial, 3=Object, 4=Honeycomb
c4d.MG_GRID_MODE: 0=Endpoint (total span), 1=Per Step (spacing)Redshift Material Extraction
RS node graphs ARE accessible via the maxon Python API when Redshift is installed. The inspect_redshift_materials MCP tool may report materials as not_redshift_like (type 5703), but the node graph is still readable through the maxon node-space API.
Primary Path: maxon Node-Space API (Proven Working)
RS materials use node space com.redshift3d.redshift4c4d.class.nodespace. Access via:
import c4d
import maxon
RS_NODESPACE = "com.redshift3d.redshift4c4d.class.nodespace"
mat = doc.GetMaterials()[0]
nm = mat.GetNodeMaterialReference()
graph = nm.GetGraph(RS_NODESPACE) # returns NodesGraphModelRef
# Traverse ALL nodes and ports:
root = graph.GetRoot()
inner = root.GetInnerNodes(maxon.NODE_KIND.ALL_MASK, False)
for n in inner:
kind = n.GetKind()
nid = str(n.GetId())
if kind == 1: # NODE — e.g. standardmaterial, incandescent, texturesampler
print(f"Node: {nid}")
elif kind == 8: # INPORT — readable port with value
short_name = nid.split('.')[-1]
try:
val = n.GetDefaultValue()
if val is not None:
print(f" {short_name} = {val}")
except:
passWhat this gives you:
- All shader nodes (RS Standard Material, Incandescent, MaxonNoise, TextureSampler, RSRamp, RSColorCorrection, etc.)
- All input port values (colors, intensities, roughness, refraction weight, texture paths, noise params, ramp stops, etc.)
GetDefaultValue()andGetEffectivePortValue()both work
To find specific node types, use:
result = maxon.GraphModelHelper.FindNodesByAssetId(
graph, "com.redshift3d.redshift4c4d.nodes.core.standardmaterial", True
)Secondary Path: Legacy GraphView (Older RS Materials)
For older RS shader-network materials where GetGraph() returns None:
import redshift
gv = redshift.GetRSMaterialNodeMaster(mat)
if gv:
root = gv.GetRoot()
child = root.GetDown()
while child:
print(f"Node: {child.GetName()} op={child.GetOperatorID()}")
child = child.GetNext()Fallback: Preview Bitmap Sampling
When neither path works (RS not installed), sample preview bitmaps for approximate colors:
bmp = mat.GetPreview(0)
if bmp:
r, g, b = bmp.GetPixel(bmp.GetBw() // 2, bmp.GetBh() // 2)Node Kind Constants
| Kind | Value | Meaning |
|---|---|---|
| NODE | 1 | Shader node (standardmaterial, incandescent, etc.) |
| INPUTS | 2 | Input ports container |
| OUTPUTS | 4 | Output ports container |
| INPORT | 8 | Individual input port (has value) |
| OUTPORT | 16 | Individual output port |
Duplicate Names
If the scene contains multiple materials with the same visible name, use material_index instead of material_name with the MCP inspector.
Clone-to-Material Mapping
Use MODATA_CLONE array from GeGetMoData() to get normalized clone indices (0.0–1.0 mapped to child objects):
md = mo.GeGetMoData(cloner)
clone_indices = md.GetArray(c4d.MODATA_CLONE) # float array, 0.0–1.0These values map to the cloner's child object cycle. Verify visually — don't assume the cycle matches hierarchy order.
Examples
Example 1: Extract MoGraph Cloner Animation to JSON for Three.js
Scenario: A cloner with 50 spheres driven by a Random effector needs to be exported as per-frame position data for playback in Three.js.
Step-by-step:
1. Health check — confirm get_scene_info returns scene name and doc.GetFps() returns 30.
2. Identify the cloner name from list_objects or get_scene_info. Assume it is "SphereCloner".
3. Run a small test bake (frames 0-10) to confirm data shape:
import c4d
from c4d.modules import mograph as mo
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None
fps = doc.GetFps()
cloner = find_obj("SphereCloner")
mg = cloner.GetMg()
for frame in range(0, 11):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
md = mo.GeGetMoData(cloner)
matrices = md.GetArray(c4d.MODATA_MATRIX)
world_positions = [list(mg * m.off) for m in matrices]
print(f"Frame {frame}: {len(world_positions)} clones, first pos: {world_positions[0]}")4. Confirm output: 50 clones per frame, positions changing frame to frame, no NaN values.
5. Run full bake using the Complete Animation Bake Workflow (Steps 3-5 above). The export JSON is saved to the system temp directory.
6. Convert to Three.js-compatible format — the JSON structure frames[frame][clone_index].position maps directly to BufferAttribute update per frame in an AnimationMixer-driven loop.
Three.js consumption pattern:
// Load the exported JSON
const data = await fetch('/data/spherecloner_export.json').then(r => r.json());
const fps = data.fps;
// On each animation frame:
function updateClones(currentTime) {
const frame = Math.floor(currentTime * fps);
const frameData = data.frames[frame];
if (!frameData) return;
frameData.forEach((clone, i) => {
meshes[i].position.set(...clone.position);
});
}---
Example 2: Debug Missing Redshift Colors Using Preview Bitmap Workaround
Scenario: A scene uses Redshift materials. You need to identify which material is which color for a clone-to-material mapping, but mat[c4d.MATERIAL_COLOR_COLOR] returns black or zero for all RS materials.
Why it fails: Redshift materials store color in the RS node graph, not in the standard C4D material container. mat[c4d.MATERIAL_COLOR_COLOR] reads the legacy C4D channel, which is empty for RS materials.
Workaround — preview bitmap sampling:
import c4d
doc_mats = doc.GetMaterials()
material_colors = []
for mat in doc_mats:
name = mat.GetName()
bmp = mat.GetPreview(0) # 0 = default preview size
if bmp is None:
material_colors.append({"name": name, "color": None, "error": "no preview"})
continue
w = bmp.GetBw()
h = bmp.GetBh()
# Sample a 3x3 grid of pixels from the center region to get a representative color
samples = []
for sx in [w // 3, w // 2, 2 * w // 3]:
for sy in [h // 3, h // 2, 2 * h // 3]:
r, g, b = bmp.GetPixel(sx, sy)
samples.append((r, g, b))
avg_r = sum(s[0] for s in samples) // len(samples)
avg_g = sum(s[1] for s in samples) // len(samples)
avg_b = sum(s[2] for s in samples) // len(samples)
material_colors.append({
"name": name,
"color_rgb_0_255": [avg_r, avg_g, avg_b],
"color_hex": f"#{avg_r:02x}{avg_g:02x}{avg_b:02x}"
})
import json
print(json.dumps(material_colors, indent=2))Caveats:
- Preview bitmaps are generated from the last render or interactive preview. If C4D hasn't rendered a preview for a material,
GetPreview()may return None or a gray placeholder. - Force a preview render by opening the Material Manager and letting thumbnails regenerate before running this script.
- For multi-layer or metallic RS materials, the sampled color is an approximation — use it for identification (which material is roughly red vs. blue) rather than precise color matching.
- Cross-reference with
MODATA_CLONEindices to build a clone -> material -> color lookup table.
Troubleshooting Quick Reference
| Symptom | Likely Cause | Fix |
|---|---|---|
ExecutePasses() fails or returns wrong data | SetTime() called after ExecutePasses() instead of before | Always: SetTime → ExecutePasses → read data |
| MoGraph matrices all identical across frames | Jumped to frame without sequential stepping | Step frames 0→N in order, never skip |
| World positions far off from viewport | Not applying global matrix | world_pos = cloner.GetMg() * m.off |
GeGetMoData() returns None | Cloner not yet evaluated at that frame | Ensure ExecutePasses ran; check cloner is not muted |
| Clone count changes per frame | Object cloner with animated child visibility | Read md.GetCount() per frame, don't assume fixed count |
| Script times out on large range | Frame range too large for single MCP call | Chunk into 100-200 frame batches, merge results |
MODATA_CLONE all 0.0 | Single-child cloner or no child cycling | Expected behavior — all clones share one child |
| Keyframes on effector not animating output | Takes system overriding take | Check doc.GetTakeData().GetCurrentTake() |
Known Errors & Workarounds
See references/errors.md for complete Python API and MCP tool error tables.
Advanced Debugging
Raw Socket Fallback
If MCP tools fail entirely but the C4D socket server is alive at 127.0.0.1:5555, you can bypass the MCP layer and send commands directly. This is a last-resort diagnostic tool, not a normal workflow path.
When to use:
- All MCP tool calls return connection errors
execute_python_scriptfails at the transport level (not a Python error)- You need to confirm the C4D server process is alive at all
Working example:
import json
import socket
def c4d_raw(command_dict, host="127.0.0.1", port=5555, timeout=10):
"""
Send a raw command to the C4D socket server and return the parsed response.
Commands mirror the MCP tool names: get_scene_info, list_objects, execute_python_script, etc.
"""
payload = json.dumps(command_dict) + "\n"
s = socket.create_connection((host, port), timeout=timeout)
try:
s.sendall(payload.encode("utf-8"))
# Read until newline (server responds with a single JSON line)
response = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
response += chunk
if b"\n" in response:
break
finally:
s.close()
return json.loads(response.decode("utf-8").strip())
# Example: check connection
result = c4d_raw({"command": "get_scene_info"})
print(result)
# Example: run a Python expression
result = c4d_raw({
"command": "execute_python_script",
"params": {"script": "print(doc.GetDocumentName())"}
})
print(result)Notes:
- The socket server may not be running if you started C4D without the MCP plugin loaded.
- Port
5555is the default. Some forks or configurations may use different ports. - Responses are newline-delimited JSON. Large responses (e.g., full scene data) will be chunked — loop on
recvuntil you have a complete JSON object.
Data Output
- Save to JSON with metadata (scene name, fps, frame range, sampling step)
json.dumps()+print()for small results,tempfile.gettempdir()for large data- Keep both raw extraction and derived model
Additional References
- references/errors.md — Python API and MCP tool error tables, security restrictions, key parameter IDs
- references/mograph-baking-guide.md — Detailed sequential frame stepping examples, chunked baking strategy, memory management
- references/redshift-workarounds.md — Redshift color sampling, material verification patterns, node graph limitations
C4D MCP Error Reference
Python API Errors
| Error | Cause | Fix |
|---|---|---|
'c4d.Vector' object is not iterable | Can't do list(vector) | Use def vec(v): return [v.x, v.y, v.z] |
module 'c4d' has no attribute 'SCENEFILTER_ANIMATION' | Constant not in this C4D build | Use hasattr() check, fallback to SCENEFILTER_OBJECTS etc. |
module 'c4d' has no attribute 'MG_POLY_MODE_CLONE_COUNT' | Wrong attribute name | Use c4d.MG_RADIAL_COUNT for radial cloners |
module 'c4d' has no attribute 'MG_RADIAL_START' | Attribute doesn't exist | Skip or try/except |
module 'c4d' has no attribute 'MG_OBJECT_ITERATION' | Wrong attribute name | Skip or try/except |
Execution on main thread timed out after 15s | Script too slow or MoGraph heavy | Simplify, reduce loops, split into chunks |
Parameter value accessible (object unknown in Python) | RS node container IDs aren't plain Python types | Skip with try/except when iterating data containers |
'c4d.Material' object has no attribute 'GetGUID' | Materials don't have GetGUID | Use mat.FindUniqueID(c4d.MAXON_CREATOR_ID) or mat.GetUniqueID() |
MCP Tool Errors
| Error | Cause | Fix |
|---|---|---|
list_objects validation error (expected string, got dict) | MCP schema mismatch (fixed in some forks) | Update server, or use execute_python_script to traverse hierarchy |
load_scene error (takes 1 positional argument but N were given) | Plugin bug: path unpacked as args | Load scene manually or via execute_python_script + LoadDocument |
render_preview validation error (expected string, got dict) | Same schema mismatch (fixed in some forks) | Update server, or skip preview |
| Frame sampling returns static values | Missing pass evaluation | Call ExecutePasses after SetTime |
| Jumping to frame X gives wrong positions | Stateful MoGraph evaluation | Step sequentially from start frame |
Security Restrictions
Banned keywords in execute_python_script: import os, os.system, subprocess, exec(, eval(.
Keep scripts within the allowed C4D API surface (c4d, c4d.modules.mograph, json, math).
Key Parameter IDs
| ID | Location | Meaning |
|---|---|---|
1041671 | Materials, Effectors | Emission color |
1100 | Random Effectors | Seed value |
1010 | Effectors | Position/rotation range |
1012 | Random.rot | Rotation range (radians) |
MoGraph Baking Guide
This reference covers the mechanics of baking MoGraph animation to per-frame data. Read this when the main SKILL.md workflow is not enough — e.g., when dealing with large scenes, variable clone counts, or performance issues.
Why Sequential Stepping Is Mandatory
MoGraph effectors (Random, Step, Formula, etc.) maintain internal state that is evaluated incrementally. The C4D scene graph does not store a precomputed result for each frame — it computes results on demand based on the current time. This means:
- Jumping directly to frame 50 without evaluating frames 0-49 gives wrong results for stateful effectors like Step Effector.
- The Random Effector with seed-based offsets is stateless — it appears to work when jumping frames — but any effector that accumulates state (Step, inheritance chains, fields with time offsets) will silently produce incorrect output.
Always step 0 → N even if you only need a subset of frames.
If you need only every 5th frame, still step through every frame but only store data on the frames you need:
for frame in range(start, end + 1):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
if frame % 5 == 0:
# store data for this frame
...ExecutePasses Arguments
doc.ExecutePasses(
bt, # BaseThread — pass None for main thread
animation, # bool — evaluate animation tracks
expressions, # bool — evaluate XPresso and Python tags
caches, # bool — evaluate caches (generators, cloners, deformers)
flags # c4d.BUILDFLAGS_NONE is the correct default
)All three bool arguments should be True for MoGraph baking. Passing False for caches skips cloner evaluation entirely and GeGetMoData() will return stale or empty data.
Chunked Baking Strategy
MCP scripts time out if they run too long (15-60 seconds depending on the server fork). For long animations or dense clone counts, split the bake into chunks and merge the resulting files.
Chunk Size Guidelines
| Clone Count | Frames per Chunk |
|---|---|
| < 50 clones | 500 frames |
| 50-200 clones | 200 frames |
| 200-500 clones | 100 frames |
| > 500 clones | 50 frames |
These are conservative estimates. Always test with a small range first and time it.
Chunked Bake Script (Parameterized)
Run this multiple times with different chunk_start / chunk_end values:
import c4d
from c4d.modules import mograph as mo
import json
CHUNK_START = 0 # change per run
CHUNK_END = 199 # change per run
CLONER_NAME = "MyClonerName"
import tempfile, os
OUTPUT_PATH = os.path.join(tempfile.gettempdir(), f"bake_chunk_{CHUNK_START}_{CHUNK_END}.json")
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None
def vec(v):
return [float(v.x), float(v.y), float(v.z)]
fps = doc.GetFps()
cloner = find_obj(CLONER_NAME)
if cloner is None:
print(f"ERROR: cloner '{CLONER_NAME}' not found")
else:
mg = cloner.GetMg()
frames_data = {}
# ALWAYS start from frame 0 to ensure stateful effectors are correct
for frame in range(0, CHUNK_END + 1):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
if frame < CHUNK_START:
continue # step through but don't store
md = mo.GeGetMoData(cloner)
if md is None:
print(f"Frame {frame}: no MoData — skipping")
continue
matrices = md.GetArray(c4d.MODATA_MATRIX)
clone_indices = md.GetArray(c4d.MODATA_CLONE)
frame_clones = []
for i, m in enumerate(matrices):
world_pos = mg * m.off
scale = (m.v1.GetLength() + m.v2.GetLength() + m.v3.GetLength()) / 3.0
frame_clones.append({
"index": i,
"position": vec(world_pos),
"scale": float(scale),
"clone_index": float(clone_indices[i]) if clone_indices else None
})
frames_data[str(frame)] = frame_clones
if frame % 25 == 0:
print(f"Progress: frame {frame}/{CHUNK_END}")
with open(OUTPUT_PATH, "w") as f:
json.dump({
"chunk_start": CHUNK_START,
"chunk_end": CHUNK_END,
"fps": fps,
"frames": frames_data
}, f)
print(f"Saved chunk to {OUTPUT_PATH}, frames: {len(frames_data)}")Merging Chunks
After all chunks are saved, merge with this script (run outside C4D, in a normal Python environment):
import json
import glob
import os
import tempfile
chunk_files = sorted(glob.glob(os.path.join(tempfile.gettempdir(), "bake_chunk_*.json")))
if not chunk_files:
print("No chunk files found")
exit(1)
merged_frames = {}
fps = None
min_start = None
max_end = None
for path in chunk_files:
with open(path) as f:
chunk = json.load(f)
fps = chunk["fps"]
min_start = min(min_start, chunk["chunk_start"]) if min_start is not None else chunk["chunk_start"]
max_end = max(max_end, chunk["chunk_end"]) if max_end is not None else chunk["chunk_end"]
merged_frames.update(chunk["frames"])
output = {
"fps": fps,
"start_frame": min_start,
"end_frame": max_end,
"total_frames": len(merged_frames),
"frames": merged_frames
}
merged_path = os.path.join(tempfile.gettempdir(), "bake_merged.json")
with open(merged_path, "w") as f:
json.dump(output, f)
print(f"Merged {len(chunk_files)} chunks → {len(merged_frames)} frames")
# Clean up chunks
for path in chunk_files:
os.remove(path)
print("Chunk files removed.")Variable Clone Count
Object-mode cloners and cloners with animated count parameters can change their clone count per frame. Always check md.GetCount() rather than assuming a fixed count:
md = mo.GeGetMoData(cloner)
count = md.GetCount()
matrices = md.GetArray(c4d.MODATA_MATRIX)
assert len(matrices) == count, f"Matrix array length mismatch: {len(matrices)} vs {count}"If the count varies, your output JSON should store the actual count per frame. Downstream consumers (Three.js, etc.) must handle variable clone counts — typically by showing/hiding mesh instances beyond the current frame's count.
Memory Management
For very large bakes (500+ clones × 1000+ frames), the frames_data dict can grow large. Consider writing results incrementally to a file rather than accumulating in memory:
import json
import tempfile
OUTPUT_PATH = os.path.join(tempfile.gettempdir(), "bake_streaming.json")
with open(OUTPUT_PATH, "w") as f:
f.write('{"frames":{')
first = True
for frame in range(start, end + 1):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
md = mo.GeGetMoData(cloner)
# ... build frame_clones list ...
if not first:
f.write(",")
f.write(f'"{frame}":' + json.dumps(frame_clones))
first = False
f.write("}}") # close frames and root object
print("Streaming bake complete.")Note: This produces valid JSON only if no exception is raised mid-loop. Wrap the loop in a try/except and write a sentinel if interrupted.
Diagnosing Stateful vs. Stateless Effectors
To determine whether an effector is stateful (requires sequential stepping) or stateless (safe to jump to arbitrary frames):
import c4d
from c4d.modules import mograph as mo
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None
fps = doc.GetFps()
cloner = find_obj("MyClonerName")
mg = cloner.GetMg()
def get_positions_at_frame(frame):
doc.SetTime(c4d.BaseTime(frame, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
md = mo.GeGetMoData(cloner)
matrices = md.GetArray(c4d.MODATA_MATRIX)
return [list(mg * m.off) for m in matrices]
# Method A: sequential
sequential_f10 = None
for f in range(0, 11):
pos = get_positions_at_frame(f)
if f == 10:
sequential_f10 = pos
# Method B: jump directly
jump_f10 = get_positions_at_frame(10)
# Compare
match = all(
abs(sequential_f10[i][j] - jump_f10[i][j]) < 0.001
for i in range(len(sequential_f10))
for j in range(3)
)
print("Stateless (jump safe):" if match else "STATEFUL — must step sequentially")Redshift Data Access
This reference covers how to access Redshift material and scene data through the C4D Python API.
Primary Path: maxon Node-Space API (Proven Working)
RS node graphs ARE fully accessible when Redshift is installed. Even though materials may appear as type 5703 (standard C4D wrappers) and the inspect_redshift_materials MCP tool may skip them as not_redshift_like, the actual RS node graph data is readable through the maxon Python API.
Complete RS Material Extraction Pattern
import c4d
import maxon
RS_NODESPACE = "com.redshift3d.redshift4c4d.class.nodespace"
doc = c4d.documents.GetActiveDocument()
for mi, mat in enumerate(doc.GetMaterials()):
nm = mat.GetNodeMaterialReference()
graph = nm.GetGraph(RS_NODESPACE)
if graph.IsEmpty():
continue
root = graph.GetRoot()
inner = root.GetInnerNodes(maxon.NODE_KIND.ALL_MASK, False)
for n in inner:
kind = n.GetKind()
nid = str(n.GetId())
if kind == 1: # NODE
print(f"Node: {nid}")
elif kind == 8: # INPORT
short_name = nid.split('.')[-1]
try:
val = n.GetDefaultValue()
if val is not None:
print(f" {short_name} = {val}")
except:
passWhat This Gives You
All RS shader node types and their port values:
- RS Standard Material: base_color, base_color_weight, metalness, refl_roughness, refr_weight, opacity_color, emission_color/weight, coat params, etc.
- RS Incandescent: color, intensity, temperature, colormode, alpha, doublesided
- TextureSampler: path (texture file), gamma, rotate, scale, offset, color_multiplier
- MaxonNoise: color1, color2, noise_type, octaves, animation_speed, coord_scale_global, seed, brightness, contrast
- RSRamp: ramp stops with position, color, interpolation
- RSColorCorrection: gamma, contrast, hue, saturation, level
Finding Specific Node Types
result = maxon.GraphModelHelper.FindNodesByAssetId(
graph, "com.redshift3d.redshift4c4d.nodes.core.standardmaterial", True
)Known RS asset IDs:
com.redshift3d.redshift4c4d.nodes.core.standardmaterialcom.redshift3d.redshift4c4d.nodes.core.incandescentcom.redshift3d.redshift4c4d.nodes.core.output
Node Kind Constants
| Kind | Value | Meaning |
|---|---|---|
| NODE | 1 | Shader node |
| INPUTS | 2 | Input ports container |
| OUTPUTS | 4 | Output ports container |
| INPORT | 8 | Individual input port (has readable value) |
| OUTPORT | 16 | Individual output port |
Key API Notes
GetGraph()returnsNodesGraphModelRef— does NOT haveIsValid(), useIsEmpty()insteadGetRoot().GetInnerNodes(maxon.NODE_KIND.ALL_MASK, False)is the reliable traversal methodGetChildren()on the root often returns empty — useGetInnerNodesinsteadGetDefaultValue()works for reading port values;GetEffectivePortValue()also works- Color values print as
R:1.0, G:1.0, B:1.0; Vec3 asX:0.2, Y:0.2, Z:0.2 - Ramp data includes nested knot entries with position, color, interpolation per stop
Secondary Path: Legacy Redshift GraphView
For older RS shader-network materials where GetGraph() returns an empty graph:
import redshift
gv = redshift.GetRSMaterialNodeMaster(mat)
if gv:
root = gv.GetRoot()
child = root.GetDown()
while child:
print(f"Node: {child.GetName()} op={child.GetOperatorID()}")
child = child.GetNext()This path is useful when the Cinema UI shows a Redshift Shader Graph but the maxon node-space API returns empty. Not all scenes need this — newer RS materials work with the primary path.
Tertiary Fallback: Preview Bitmap Sampling
When Redshift is not installed at all, sample preview bitmaps for approximate colors:
bmp = mat.GetPreview(0)
if bmp:
w, h = bmp.GetBw(), bmp.GetBh()
r, g, b = bmp.GetPixel(w // 2, h // 2)This is an approximation — use it only for rough color identification when the above methods are unavailable.
What You CAN Access
With RS Installed (primary path)
- Full RS node graph: all shader nodes, all port values, texture paths, noise params, ramp stops
- Material assignments via
Ttexturetags - Material preview bitmaps
import redshiftmodule for legacy GraphView access
Without RS Installed
- Material names, hierarchy, assignments
- Preview bitmaps (if cached)
- BaseContainer values (limited — RS params stored in node graph, not container)
- Object transforms, visibility, hierarchy
- Cloner clone-to-material index data (
MODATA_CLONE)
Still Not Accessible via Python API
- RS light color/intensity/exposure (stored at container IDs 10000+ but all read as defaults; likely need RS light node graph access which is not exposed the same way as materials)
- Node connections/wiring between RS material nodes (which output connects to which input) —
GetConnections()exists but hasn't been tested - RS environment settings internals
RS Color Sampling via Preview Bitmap
RS materials store color in the node graph, not the legacy C4D color channel. mat[c4d.MATERIAL_COLOR_COLOR] will return black or zero for all RS materials.
Basic Color Sampling
import c4d
import json
doc_mats = doc.GetMaterials()
results = []
for mat in doc_mats:
name = mat.GetName()
bmp = mat.GetPreview(0)
if bmp is None:
results.append({"name": name, "error": "no_preview"})
continue
w = bmp.GetBw()
h = bmp.GetBh()
if w == 0 or h == 0:
results.append({"name": name, "error": "zero_size_preview"})
continue
# Sample center pixel
cx, cy = w // 2, h // 2
r, g, b = bmp.GetPixel(cx, cy)
results.append({
"name": name,
"color_rgb": [r, g, b],
"color_hex": f"#{r:02x}{g:02x}{b:02x}",
"preview_size": [w, h]
})
print(json.dumps(results, indent=2))Multi-Point Sampling for Better Accuracy
Single-pixel sampling can hit specular highlights or dark shadow regions. Sample a grid and average:
import c4d
import json
def sample_material_color(mat, grid_size=5):
bmp = mat.GetPreview(0)
if bmp is None:
return None
w = bmp.GetBw()
h = bmp.GetBh()
if w == 0 or h == 0:
return None
# Avoid edges (first and last 20% of dimensions) to skip shadow/highlight regions
margin_x = w // 5
margin_y = h // 5
x_start, x_end = margin_x, w - margin_x
y_start, y_end = margin_y, h - margin_y
samples = []
for i in range(grid_size):
for j in range(grid_size):
x = x_start + (x_end - x_start) * i // (grid_size - 1)
y = y_start + (y_end - y_start) * j // (grid_size - 1)
r, g, b = bmp.GetPixel(x, y)
samples.append((r, g, b))
avg_r = sum(s[0] for s in samples) // len(samples)
avg_g = sum(s[1] for s in samples) // len(samples)
avg_b = sum(s[2] for s in samples) // len(samples)
return {
"rgb": [avg_r, avg_g, avg_b],
"hex": f"#{avg_r:02x}{avg_g:02x}{avg_b:02x}",
"sample_count": len(samples)
}
for mat in doc.GetMaterials():
color = sample_material_color(mat)
print(f"{mat.GetName()}: {color}")Forcing Preview Regeneration
If GetPreview() returns None or a gray placeholder, the material has no cached preview. There is no reliable way to force preview generation from Python without user interaction. The workaround is:
1. Open C4D's Material Manager (Window → Material Manager) 2. Let all material thumbnails render (they render in the background) 3. Then re-run the sampling script
Alternatively, use the RS material sphere render approach (see below).
RS Material Verification via Isolated Sphere Render
For precise color identification, temporarily assign each material to an isolated sphere and render a small preview frame:
import c4d
import json
# This approach: create a sphere, assign each material, render one frame,
# sample the center of the rendered bitmap.
# WARNING: This modifies the scene temporarily. Always undo or restore.
doc_mats = doc.GetMaterials()
results = []
# Create a temporary sphere
sphere = c4d.BaseObject(c4d.Osphere)
sphere.SetName("__temp_color_sphere__")
doc.InsertObject(sphere)
for mat in doc_mats:
name = mat.GetName()
# Assign material to sphere
tag = sphere.MakeTag(c4d.Ttexture)
tag[c4d.TEXTURETAG_MATERIAL] = mat
# Force scene update
doc.SetTime(doc.GetTime())
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
# Read preview (this is the material's own preview, not a render)
bmp = mat.GetPreview(0)
if bmp:
w, h = bmp.GetBw(), bmp.GetBh()
r, g, b = bmp.GetPixel(w // 2, h // 2)
results.append({"name": name, "color_hex": f"#{r:02x}{g:02x}{b:02x}"})
else:
results.append({"name": name, "color_hex": None})
# Remove tag
tag.Remove()
# Remove temporary sphere
sphere.Remove()
c4d.EventAdd()
print(json.dumps(results, indent=2))Material Assignment Verification
To confirm which material is assigned to which object (without relying on RS APIs):
import c4d
import json
def find_material_assignments():
assignments = []
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
# Check texture tags
tag = obj.GetFirstTag()
while tag:
if tag.GetType() == c4d.Ttexture:
mat = tag[c4d.TEXTURETAG_MATERIAL]
if mat:
assignments.append({
"object": obj.GetName(),
"material": mat.GetName(),
"tag_projection": tag[c4d.TEXTURETAG_PROJECTION]
})
tag = tag.GetNext()
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return assignments
assignments = find_material_assignments()
print(json.dumps(assignments, indent=2))Clone-to-Material Color Mapping
Full pipeline: MODATA_CLONE indices → material name → sampled color:
import c4d
from c4d.modules import mograph as mo
import json
def find_obj(name):
stack = [doc.GetFirstObject()]
while stack:
obj = stack.pop()
while obj:
if obj.GetName() == name:
return obj
if obj.GetDown():
stack.append(obj.GetDown())
obj = obj.GetNext()
return None
def sample_color(mat):
bmp = mat.GetPreview(0)
if bmp is None:
return None
w, h = bmp.GetBw(), bmp.GetBh()
if w == 0 or h == 0:
return None
r, g, b = bmp.GetPixel(w // 2, h // 2)
return f"#{r:02x}{g:02x}{b:02x}"
fps = doc.GetFps()
cloner = find_obj("MyClonerName")
# Step to a representative frame first
doc.SetTime(c4d.BaseTime(0, fps))
doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE)
md = mo.GeGetMoData(cloner)
clone_indices = md.GetArray(c4d.MODATA_CLONE)
# Get child objects of the cloner (the "cycle" objects)
children = []
child = cloner.GetDown()
while child:
children.append(child)
child = child.GetNext()
# Map: child index -> material -> color
child_colors = []
for child_obj in children:
tag = child_obj.GetFirstTag()
mat_name = None
color = None
while tag:
if tag.GetType() == c4d.Ttexture:
mat = tag[c4d.TEXTURETAG_MATERIAL]
if mat:
mat_name = mat.GetName()
color = sample_color(mat)
break
tag = tag.GetNext()
child_colors.append({"child": child_obj.GetName(), "material": mat_name, "color": color})
# Map each clone to its color
clone_color_map = []
for i, idx in enumerate(clone_indices):
child_slot = int(round(idx * (len(children) - 1))) if len(children) > 1 else 0
child_slot = max(0, min(child_slot, len(children) - 1))
clone_color_map.append({
"clone": i,
"clone_index": float(idx),
"child_slot": child_slot,
"color": child_colors[child_slot]["color"] if child_slot < len(child_colors) else None
})
print(json.dumps({
"child_colors": child_colors,
"clone_color_map": clone_color_map[:10] # first 10 for verification
}, indent=2))RS Node Graph Parameter IDs
RS parameter IDs are dynamic — they depend on the RS version installed and the specific node type. There is no stable, version-independent list. However, known IDs found in practice:
| ID | Context | Meaning |
|---|---|---|
1041671 | RS Standard Material, RS Emission node | Emission color |
1100 | Random Effector | Seed value |
2000 | RS Material (various) | Base color slot (version-dependent) |
Do not hardcode RS parameter IDs without confirming them against the actual C4D version in use. Use try/except around any RS container access:
try:
color = mat[1041671]
if color is not None:
print(f"Emission color: {color}")
except Exception as e:
print(f"Could not read RS param 1041671: {e}")Iterating RS Material Data Container
Attempting to iterate all keys in an RS material's data container will raise Parameter value accessible (object unknown in Python) on RS-type entries. Use defensive iteration:
import c4d
for mat in doc.GetMaterials():
bc = mat.GetDataInstance()
if bc is None:
continue
# Iterate known safe ranges only — do NOT use bc.GetClone() or full iteration
safe_ids = [c4d.MATERIAL_COLOR_COLOR, c4d.MATERIAL_LUMINANCE_COLOR, c4d.MATERIAL_USE_COLOR]
for param_id in safe_ids:
try:
val = mat[param_id]
if val is not None:
print(f" {param_id}: {val}")
except Exception:
pass # RS material — this param doesn't exist in the standard containerKnown Limitations Summary
| Operation | Status | Method |
|---|---|---|
| Read RS material base color | Working | maxon node-space API: GetDefaultValue() on INPORT nodes |
| Read RS material all params | Working | Full node graph traversal via GetInnerNodes() |
| Read RS texture paths | Working | TextureSampler node path port |
| Read RS noise/ramp params | Working | MaxonNoise, RSRamp node ports |
| Read RS node graph connections | Untested | GetConnections() exists but not yet verified |
| Read RS light color/intensity | Not working | Container IDs 10000+ return defaults; light node graph not exposed |
| Read RS environment shader | Not working | No known API path |
| Confirm material assignment | Working | Iterate Ttexture tags |
| Get all material names | Working | doc.GetMaterials() |
| Preview bitmap color sampling | Working (fallback) | mat.GetPreview(0).GetPixel() |