Auditing Redshift Textures in Cinema 4D with Python
30 Aug 26 (2mo ago)
Finding every texture used in a Cinema 4D project can be challenging when using Redshift. Standard C4D asset tools often fail to see inside Redshift's Node Materials, leaving textures hidden within deeply nested shader graphs.
This becomes especially problematic when trying to clean up project directories, locate missing assets, or consolidate files for rendering.
The Python Script Solution
To resolve this, you can use a custom Python script designed to deeply scan Redshift Node Materials. The script below traverses the node graphs, locates the specific Redshift Texture Sampler nodes, and recursively digs through port inputs to extract the active file paths.
Key Features
- Deep Scanning: Recursively searches through nested node ports to locate texture strings.
- Directory Auditing: Compares the textures used in the active scene against a specified local directory to identify orphaned (unused) files and missing assets.
- UDIM Resolution: Automatically detects abstract
<UDIM>tags in the scene and matches them to the physical files (e.g.,1001,1002) stored on your drive. - Condensed Output: Groups discovered UDIM sequences in the console output to keep the diagnostic report clean and readable, supporting both dot (
.1001.) and underscore (_1001.) naming conventions.
The Code
To use this tool, open the Script Manager (Extensions > Script Manager), create a new script, paste the code below, and execute it.
Important: Update the
TARGET_DIRECTORYvariable at the top of the script to match your project's texture folder path before running.
import c4d
import maxon
import os
import re
# --- CONFIGURATION ---
TARGET_DIRECTORY = r"D:\sciepro_girl\c4d\tex"
VALID_EXTS = ['.jpg', '.jpeg', '.png', '.tif', '.tiff', '.exr', '.bmp', '.psd', '<udim>']
def get_files_in_directory(directory):
if not os.path.exists(directory):
print(f"[ERROR] Directory not found: {directory}")
return []
files_on_disk = []
try:
for f in os.listdir(directory):
if os.path.isfile(os.path.join(directory, f)):
files_on_disk.append(f)
except Exception as e:
print(f"Error reading directory: {e}")
return files_on_disk
def find_texture_paths_in_node(port_root, collected_paths):
children = port_root.GetChildren()
for port in children:
is_path_id = (port.GetId().ToString() == "path")
try:
data = port.GetDefaultValue()
except:
data = None
path_str = ""
if isinstance(data, maxon.Url):
path_str = data.GetUrl()
elif isinstance(data, str):
path_str = data
if path_str and len(path_str) > 1:
is_valid_file = any(ext in path_str.lower() for ext in VALID_EXTS)
if is_path_id or is_valid_file:
clean_path = path_str.replace("file:///", "").replace("relative://", "")
clean_path = clean_path.lstrip('/')
if clean_path not in collected_paths:
collected_paths.append(clean_path)
find_texture_paths_in_node(port, collected_paths)
def get_scene_textures():
doc = c4d.documents.GetActiveDocument()
if not doc: return []
materials = doc.GetMaterials()
print(f"--- Scanning {len(materials)} Scene Materials ---")
redshift_nodespace_id = maxon.Id("com.redshift3d.redshift4c4d.class.nodespace")
rs_texture_asset_id = maxon.Id("com.redshift3d.redshift4c4d.nodes.core.texturesampler")
scene_textures = []
for mat in materials:
node_material = mat.GetNodeMaterialReference()
try:
graph = node_material.GetGraph(redshift_nodespace_id)
except:
continue
if graph.IsNullValue(): continue
tex_nodes = []
maxon.GraphModelHelper.FindNodesByAssetId(graph, rs_texture_asset_id, True, tex_nodes)
for node in tex_nodes:
find_texture_paths_in_node(node.GetInputs(), scene_textures)
return scene_textures
def condense_unused_list(file_list):
"""
Groups UDIM sequences like:
- name.1001.jpg
- name_1001.jpg
"""
pattern = re.compile(r"(.+?)([._])(\d{3,4})\.(.+)$")
singles = []
udim_groups = {}
for f in file_list:
match = pattern.match(f)
if match:
name_part = match.group(1)
sep_part = match.group(2)
number_part = match.group(3)
ext_part = match.group(4)
key = (name_part, sep_part, ext_part)
if key not in udim_groups:
udim_groups[key] = []
udim_groups[key].append(number_part)
else:
singles.append(f)
final_list = list(singles)
for (name, sep, ext), numbers in udim_groups.items():
numbers.sort()
lowest = numbers[0]
rep_name = f"{name}{sep}{lowest}.{ext}"
final_list.append(rep_name)
return final_list
def main():
scene_texture_list = get_scene_textures()
disk_file_list = get_files_in_directory(TARGET_DIRECTORY)
# Normalize
scene_filenames = set()
for path in scene_texture_list:
scene_filenames.add(os.path.basename(path))
disk_filenames = set(disk_file_list)
unused_files = disk_filenames - scene_filenames
missing_files = scene_filenames - disk_filenames
resolved_udims = {}
# --- UDIM RESOLVER ---
for missing_name in list(missing_files):
tag = ""
if "<UDIM>" in missing_name: tag = "<UDIM>"
elif "<udim>" in missing_name: tag = "<udim>"
if tag:
parts = missing_name.split(tag)
if len(parts) == 2:
prefix = parts[0]
suffix = parts[1]
matches = []
for candidate in list(unused_files):
if candidate.lower().startswith(prefix.lower()) and candidate.lower().endswith(suffix.lower()):
expected_len = len(candidate) - len(prefix) - len(suffix)
if expected_len >= 3:
matches.append(candidate)
if matches:
resolved_udims[missing_name] = len(matches)
missing_files.remove(missing_name)
for m in matches:
if m in unused_files:
unused_files.remove(m)
# --- OUTPUT ---
print("\n" + "="*50)
print(f"DIAGNOSTIC REPORT")
print("="*50)
print(f"Total Textures Paths in Scene: {len(scene_filenames)}")
print(f"Total Files in Folder: {len(disk_filenames)}")
print("-" * 50)
# --- CONDENSE THE UNUSED LIST ---
if unused_files:
condensed_list = condense_unused_list(list(unused_files))
print(f"\n[UNUSED / TO DELETE] Found {len(unused_files)} actual files (Condensed view):")
print("-" * 50)
for f in sorted(condensed_list):
print(f" [x] {f}")
else:
print("\n[CLEAN] No unused files found.")
if resolved_udims:
print("-" * 50)
print(f"\n[UDIMs DETECTED & VERIFIED]")
for name, count in resolved_udims.items():
print(f" [OK] {name} >>> Found {count} tiles on disk")
if missing_files:
print("-" * 50)
print(f"\n[MISSING / EXTERNAL] Used in scene but NOT in folder:")
for f in sorted(list(missing_files)):
print(f" [?] {f}")
print("="*50)
c4d.CallCommand(12305)
if __name__ == '__main__':
main()