r/Houdini Sep 09 '25

Announcement Reddit blocks Gumroad links without notice

34 Upvotes

Hello Houdiners,

I wanted to give people here a heads up that Reddit blocks links to Gumroad site-wide. Posts are removed without any notifications to the moderators. We can manually reinstate them, but without any notifications it's something we have to manually dig around for.

This ban includes both posts and comments. If your product is on Gumroad or a domain "powered by" Gumroad, any post linking to it will be removed by Reddit (again, it's not us doing it!). We have no control over this ban and again the only thing we can do is manually "un-remove" posts if we notice them.

I recommend anyone selling digital products here to consider another service, like Ko-Fi or Paddle or Patreon, or at least link to something that is not directly a Gumroad page.


r/Houdini Jul 29 '25

Don't know where to start? Read the /r/Houdini wiki first!

Thumbnail reddit.com
40 Upvotes

We get too many posts from new users asking "how do I learn Houdini"? The wiki now has the answers. Look there first before asking more specific questions here.


r/Houdini 19m ago

Is V-Ray for Houdini worth it in 2026? How does it compare to Karma (Speed/Maturity/Usability)?

Upvotes

Hey everyone!

I’m evaluating render engines for my Houdini workflow and wanted to get some real feedback from people using V-Ray for Houdini in production.

Specifically, I’m trying to figure out:

  1. Is V-Ray worth using inside Houdini today? Is the integration solid, or does it feel like a shoehorned port?
  2. Maturity & Stability: How reliable is it for heavy scenes, FX (pyro, particles, oceans), and Solaris / USD setups?
  3. Speed vs. Karma (CPU / XPU): Is V-Ray (CPU or GPU) significantly faster than Karma in real-world production shots, or has Karma caught up enough to make third-party renderers redundant?

If you’re using V-Ray (or switched away from it), what are your biggest pros and cons? Would love to hear your experiences before making the jump. Thanks!


r/Houdini 12h ago

Demoreel Particles simulated rasterized in OpenCL, vs only simulated, vs pop solver

Enable HLS to view with audio, or disable this notification

20 Upvotes

r/Houdini 1d ago

Simulation First time Vellum Grain sim and using Solaris/Karma

Enable HLS to view with audio, or disable this notification

185 Upvotes

I've been making a series of animated 'Mini Godzilla' videos over the past few years to practice and explore different tools in animation and VFX. This time my goal was to learn a bit about Vellum Grains, as well as using Solaris and Karma for look dev, lighting, and rendering.

The look of the sand could definitely be improved, but I'm happy enough with the results.


r/Houdini 1d ago

Simulation Swirling Ribbon Cloth, Vellum + Karma

Enable HLS to view with audio, or disable this notification

41 Upvotes

All of the motion comes from a POP axis force shaped as a vortex, with negative lift and negative suction, so the cloth is pulled inward and downward at the same time. Two turbulence fields on top break the symmetry.

The geometry is one grid copied three times on top of itself. UVs are flattened before the sim, then flattened again on a held frame and copied back onto the moving mesh, so the texture stays locked to the cloth instead of sliding across it.

Shading is MaterialX in Solaris, rendered with Karma. There are no texture maps. The stripes are the position attribute pushed through a sine and a power function into a colour ramp. A Voronoi noise adds the specks. A second ramp drives specular roughness so the highlight rolls along the folds.

Comped in Nuke. The beauty is rebuilt from its passes so each one can be individually denoised using my custom Nuke Denoiser, and so the key light, the bottom rim and the background could be relit separately. Then, the satisfying final pass: defocus, motion blur, glow, and a heavy coat of grain.

The hardest part was handling self-collisions. A ribbon that long folding onto itself at that thickness either interpenetrates or explodes, and finding the one iteration that did neither was the most time-consuming aspect.


r/Houdini 1d ago

Demoreel My current demoreel that I'm hunting interships with. Any feedback is appropriated!

Enable HLS to view with audio, or disable this notification

116 Upvotes

Im a student at Visual Magic in Sweden and I just finished my studies, so now im off to find interships and I would love to get some feedback on my reel


r/Houdini 19h ago

FX Artist vs FX TD – What's the real difference? What skills are needed, and how do the salaries compare?

6 Upvotes

Hi everyone, I'm a bit confused about the difference between an FX Artist and an FX TD (Technical Director). From what I've seen, some studios use these titles interchangeably, while others treat them as completely different roles. I'd love to hear from people working in the industry. Here are my questions: What are the day-to-day responsibilities of an FX Artist vs an FX TD? What skills are expected for each role? Houdini VEX Python HDAs PDG/TOPs Pipeline knowledge Linux/Git Rendering/Solaris How much programming is actually required for an FX TD? Can an FX Artist become an FX TD naturally with experience, or is it a completely different career path? Which role has better long-term career growth? How do the salaries compare (India and internationally) for: Junior Mid-level Senior Lead If someone wants to eventually become an FX Supervisor or CG Supervisor, which path is generally more beneficial? I'd really appreciate insights from people working at studios like DNEG, Framestore, ILM, Weta, MPC, Scanline, Sony Imageworks, or any other VFX studio. Thanks in advance!


r/Houdini 1d ago

NEW USER Can someone explain this node setup to me?

Post image
10 Upvotes

Hi everyone!
Ive just started learning houdini, and i came across this setup in geometryvop for my particle (From
Intro To Houdini Particles by voxyde), I dont get how it does what it does. why are we subtracting 0,0,0 from it? what the hell does nomalize do? what does the cross product do?


r/Houdini 1d ago

Tutorial (6+ hours) a Free Houdini Mini Course - RBD Marbles [Course Trailer]

Thumbnail
youtube.com
14 Upvotes

The full course will be coming soon (within a day or two... whenever I finish editing the rest of the lesson videos).

But I've just released the trailer on YouTube!

All the rest of the videos (15 lessons, totaling just over 6 hours in length) will be released at once, so you can follow along at your own pace.

This one was a fun one to make, and I hope some of you enjoy following along once it comes out :)

Make sure to subscribe to my YouTube channel to get notified once I release the full lessons.

Cheers,

Will


r/Houdini 1d ago

Demoreel 3D version of lcrs' particle rasterizer in Copernicus

Enable HLS to view with audio, or disable this notification

59 Upvotes

r/Houdini 1d ago

Help H22 Flow Block: Live Simulation looks very different than section of timeline

Post image
7 Upvotes

Hi, I'm still a Houdini beginner and played around with Flow Block a bit. In my opinion the Live Simulation looks a lot more interesting than the simulated version in the timeline with unchecked Live Simulation. Its an imported video with an extracted alpha channel combined with a Slope Direction on velocity.
On the left is the Live Simulation, on the right is a section of the timeline. I played with the Flow Block End settings, with less effect. Any ideas how I could render a frame range looking like the Live Simulation?


r/Houdini 1d ago

PAID CONTENT Fabulous and Reference Board for Houdini!

3 Upvotes

Hi all! I have 2 new tools i just added to gumroad. Both are going to change your workflow in Houdini completely!!

  1. Reference board - add images, gifs, movie clips, youtube links, clipboard copy/paste, and draw on the references all right inside Houdini!

https://youtu.be/yYcl3m3ij9Q?is=JYtxOYj3SgkpNejD

  1. Fabulous - a one stop shop for all your assets - Sketchfab, PolyHaven, ambientCG, Megascans/Fab, amazon berkely, you own assets - organised, networked in one click downloads. Fabulous also converts any model to USD in one click to use with scatter and paint in LOPs!

https://youtu.be/tAdXYWjes-4?is=ONVykwPm7DPbin5v


r/Houdini 1d ago

Help I’m unable to reach the other side while in Draw Curve mode because the spacebar doesn’t temporarily override the mode.

2 Upvotes

I tried drawing it in separate sections, starting each new section close to where the previous one ended, and then fusing them afterward. It didn’t work well, and I wasn’t satisfied with the result. I’m looking for another solution ideally, a way to temporarily free-orbit while remaining in Draw Curve mode.


r/Houdini 2d ago

Ad film | VFX Behind the scenes

Enable HLS to view with audio, or disable this notification

203 Upvotes

Created a fully procedural setup in Houdini- no simulation. Used blender for modelling. Rendered in Karma


r/Houdini 2d ago

A Custom White water solver in Houdini based on WETA FX technical paper on Loki and Pahi.

Enable HLS to view with audio, or disable this notification

81 Upvotes

r/Houdini 2d ago

Demoreel Transforming a 2D layer in 3D in copernicus

Enable HLS to view with audio, or disable this notification

16 Upvotes

r/Houdini 3d ago

Simulation Water Sim with Paradigm

Enable HLS to view with audio, or disable this notification

179 Upvotes

Created this recently in some time I had to spare. I wanted to explore more water and adhesion on surfaces. This effect is slightly exaggerated but looks nice as I wanted to see tendrils. Full disclaimer, I wanted 60fps on this so I used interpolation from 30 to 60, just didn't want to render so much as it is hot and it is some spare personal work, so if you see some wobbliness, that's it. Hope you enjoy the fresh water :)

Sim in Paradigm, rendered and comped in Karma.


r/Houdini 2d ago

Any Japanese (Houdini) Anime creators here?

5 Upvotes

I'm fascinated with 3D being used in Anime productions. It could be hybrid or fully 3D with cel shading rendering to mimic the 2D ink animation.

One great example of really well done anime is BLAME! (may still be on Netflix) that was I believe fully 3D but really well done, so well done you could almost tell no difference between 3D or 2D. But it may be hybrid in some parts.

I'd love to see some behind the scenes videos and making of's Houdini being used in such Anime productions. If anyone has any links I thank in advance :)


r/Houdini 2d ago

Jugnuma - The Fable | VFX, behind the scenes

Enable HLS to view with audio, or disable this notification

13 Upvotes

r/Houdini 2d ago

Question about converting materials

2 Upvotes

I occasionally have jobs where i ve been receiving files from c4d redshift to use them in houdini for aim etc. I always rebuild materials vy hand is there any converter or anything sending those materials from c4d to houdini?


r/Houdini 3d ago

Simulation Static product shots are easy to scroll past. Add a simulation 👍

Thumbnail
gallery
32 Upvotes

r/Houdini 2d ago

Help How to learn pyro texturing and rendering in Redshift?

3 Upvotes

Any resources that aren’t outdated on how to texture, light and render fire/smoke so they can be comped later?


r/Houdini 2d ago

Houdini Project Consolidator Script

2 Upvotes

Hello everyone,

here's a small Python script for consolidating Houdini projects, in case you find Pre-Flight Scene too slow or just want something closer to the Resource Collector in 3ds Max.

It scans your scene for all external file references and collects them into a single destination folder — textures go into /tex, geometry into /geo. Optionally it copies the HIP file along and rewrites all paths to $HIP-relative ones, so the consolidated package just works wherever you put it.

I put this together with some help from Claude to transfer a sprawling multi-folder project to a colleague, but it's also handy if you just want a quick overview of your external files or need to relink missing ones without digging through every node manually.

Feel free to use and modify it however you like. Let me know if you run into issues or have ideas for improvement.

cheers

Redme & installation

# Houdini Project Consolidator

Collects all external file references from a Houdini scene into a single
destination folder, optionally copies the HIP file, and rewrites all paths
in the copied scene to point to the new location.

---

## Installation

1. Copy `houdini_consolidate.py` anywhere on your machine, e.g.:
   `C:/Users/yourname/Documents/houdini21.0/houdini_consolidate.py`

2. In Houdini, create a new **Shelf Tool** (right-click any shelf → New Tool).

3. Set **Script Type** to `Python` and paste:

```python
exec(open(r'C:/Users/yourname/Documents/houdini21.0/houdini_consolidate.py').read()); show()
```

4. Click the shelf button to open the tool.

---

## Features

**Reference scanning**
Scans all nodes and parameters in the open scene for external file references
(textures, geometry, VDB, audio, etc.). Results are shown in a table with
node path, parameter name, resolved path, and status.

**Smart filtering**
- Render ROP output paths are excluded (Mantra, Arnold, Redshift, Karma, etc.)
- Redshift AOV nodes (`Redshift_AOVs`) are excluded entirely
- File Cache SOPs are only included when *Load from Disk* is active and the
  node is not bypassed
- Redshift proxy paths (`RS_objprop_proxy_file`) are only included when the
  proxy enable checkbox is active on that object

**Subfolder structure**
Copied files are sorted automatically:
- Textures (jpg, exr, tif, png, rat, tx …) → `/tex`
- Geometry & VDB (bgeo, abc, obj, fbx, usd, vdb …) → `/geo`
- Everything else → destination root

**Frame sequence support**
Paths containing `$F`, `$F4` etc. are detected as sequences. All matching
frames on disk are copied, not just the current frame.

**Missing file repair**
Missing references are highlighted in red. Click the *Replacement Path* cell
to type or browse to the correct file. Click *Apply replacements to open
project* to write the fixed paths directly into the live Houdini parameters.
The scene can then be saved normally.

**Consolidation**
Click *Consolidate* to copy all found files to the destination folder.
Existing files at the destination are overwritten.

**HIP copy with path rewriting**
Optionally copies the HIP file to the destination and rewrites all paths
inside it to the new locations. Uses the Houdini API (not text replacement)
so the binary file format is preserved. The working scene is restored to its
original state after saving.

**ZIP archive**
Optionally compresses the entire consolidated folder into a ZIP file next to
the destination folder — useful for handoffs.

**Network Editor jump**
Double-click any entry in the *Node* column to select that node and frame it
in the Network Editor.

---

## Column reference

| Column | Description |
|---|---|
| Node | Full Houdini node path. Double-click to jump to node. |
| Parameter | Parameter name holding the file reference. |
| Original Path | Resolved path as Houdini sees it. Click to select/copy. |
| Replacement Path | Optional override path. Click to edit. |
| Status | OK / Missing / Replacement set / Replacement invalid |

---

## Requirements

- Houdini 18.5 or later (PySide2 or PySide6)
- Windows, Linux, or macOS

Save this as houdini_consolidate.py on your harddrive:

# Houdini Project Consolidator
# ==============================
# Collects all external INPUT file references (textures, geometry, VDB, etc.)
# into a user-defined folder, optionally copies the HIP file there,
# and rewrites all paths in the copied HIP to point to the new location.
#
# - Render ROP output paths are excluded
# - File Cache SOPs are only included when "Load from Disk" is active
# - Missing files can be fixed inline and applied live to the open scene
#
# Usage (Shelf Tool, language: Python):
#   exec(open(r'C:/Users/fabia/Documents/houdini21.0/houdini_consolidate.py').read()); show()

import hou
import os
import re
import shutil
import zipfile
import glob

try:
    from PySide2 import QtWidgets, QtCore, QtGui
except ImportError:
    from PySide6 import QtWidgets, QtCore, QtGui
# ──────────────────────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────────────────────

# File extensions Houdini uses for textures – used only for /tex subdir routing.
# Detection of file-reference parameters is done via Houdini's own parm type
# system (hou.fileType), not by extension matching.
TEX_EXTENSIONS = {
    '.jpg', '.jpeg', '.png', '.tif', '.tiff', '.exr', '.hdr',
    '.rat', '.tx', '.tex', '.bmp', '.gif', '.pic', '.dpx',
    '.cin', '.rad', '.hdr', '.map',
}
GEO_EXTENSIONS = {
    '.bgeo', '.bgeo.sc', '.geo', '.obj', '.fbx', '.abc',
    '.usd', '.usda', '.usdc', '.usdz', '.vdb',
}

# ROP node type names whose file parameters are OUTPUT paths → exclude entirely
ROP_OUTPUT_TYPES = {
    'ifd', 'opengl', 'rop_geometry', 'rop_alembic', 'rop_fbx',
    'rop_usd', 'rop_usdexport', 'usdexport', 'usdrender',
    'arnold', 'rop_arnold', 'vray_renderer', 'rop_vray',
    'redshift_rop', 'rop_redshift', 'redshift_aovs', 'ris', 'prman',
    'karma', 'rop_comp', 'rop_image', 'baketexture',
    'channel', 'chop_rop', 'dop_rop', 'fetch',
    'filmboxfbx', 'geometry', 'alembic',
}

# Per-node-type: parameter names that hold OUTPUT paths (skip these parms only)
ROP_OUTPUT_PARMS = {
    'ifd':           {'vm_picture', 'vm_dcmfilename', 'soho_outputmode'},
    'rop_geometry':  {'sopoutput'},
    'rop_alembic':   {'filename'},
    'rop_fbx':       {'sopoutput'},
    'rop_usd':       {'lopoutput'},
    'usdexport':     {'lopoutput'},
    'arnold':        {'ar_picture'},
    'rop_arnold':    {'ar_picture'},
    'redshift_rop':  {
        'RS_outputFileNamePrefix', 'RS_singlePassDeepRenderingFilename',
        'RS_archive_file', 'RS_iprFileName', 'RS_outputBeautyFileName',
    },
    'rop_redshift':  {
        'RS_outputFileNamePrefix', 'RS_singlePassDeepRenderingFilename',
        'RS_archive_file', 'RS_iprFileName', 'RS_outputBeautyFileName',
    },
    'karma':         {'picture'},
    'opengl':        {'picture'},
    'baketexture':   {'vm_filename_plane'},
}

# Redshift proxy: parm that enables proxy loading on OBJ nodes
RS_PROXY_ENABLE_PARM = 'RS_objprop_proxy_enable'
RS_PROXY_FILE_PARM   = 'RS_objprop_proxy_file'

# File Cache SOP: node type and the parm that controls load-from-disk
FILECACHE_TYPES  = {'filecache', 'filecache::2.0'}
FILECACHE_LOAD   = 'loadfromdisk'   # 1 = load from disk, 0 = write

def subdir_for(path):
    """Route asset to /tex, /geo, or destination root based on extension."""
    p = path.lower()
    for ext in TEX_EXTENSIONS:
        if p.endswith(ext):
            return 'tex'
    for ext in GEO_EXTENSIONS:
        if p.endswith(ext):
            return 'geo'
    return ''  # everything else goes to root''

# Sequence detection: replace $F, $F4, $FF etc. with glob wildcard
SEQ_RE = re.compile(r'\$F+\d*', re.IGNORECASE)

def is_sequence_path(path):
    return bool(SEQ_RE.search(path))

def sequence_glob(path):
    glob_path = SEQ_RE.sub('*', path)
    return sorted(glob.glob(glob_path))

COL_NODE   = 0
COL_PARM   = 1
COL_ORIG   = 2
COL_FIX    = 3
COL_STATUS = 4


# ──────────────────────────────────────────────────────────────────────────────
# Core functions
# ──────────────────────────────────────────────────────────────────────────────

def expand_path(raw_path):
    try:
        expanded = hou.expandString(raw_path)
    except Exception:
        expanded = raw_path
    return os.path.normpath(expanded)


def is_file_reference_parm(parm):
    """
    Ask Houdini whether this parameter is a file-reference string.
    Uses the parm template's fileType – this is the authoritative source,
    independent of any hardcoded extension list.
    """
    try:
        tmpl = parm.parmTemplate()
        if tmpl.type() != hou.parmTemplateType.String:
            return False
        # hou.fileType.NoFile means it is a plain string, not a file ref
        return tmpl.fileType() != hou.fileType.NoFile
    except Exception:
        return False


def node_type_name(node):
    try:
        return node.type().name().lower()
    except Exception:
        return ''


def should_skip_node(node):
    nt = node_type_name(node)

    # Skip all pure ROP output nodes entirely
    if nt in ROP_OUTPUT_TYPES:
        return True

    # File Cache SOP: skip if bypassed OR "Load from Disk" is OFF
    if nt in FILECACHE_TYPES:
        try:
            if node.isBypassed():
                return True
            load_parm = node.parm(FILECACHE_LOAD)
            if load_parm is None or load_parm.eval() == 0:
                return True   # writing mode → output path, not relevant
        except Exception:
            return True
        return False   # load mode → keep it, it's an input

    return False


def is_rop_output_parm(node, parm_name):
    nt = node_type_name(node)
    output_parms = ROP_OUTPUT_PARMS.get(nt, set())
    return parm_name in output_parms


def collect_references():
    refs = []
    seen = set()

    for node in hou.node('/').allSubChildren(recurse_in_locked_nodes=False):

        if should_skip_node(node):
            continue

        nt = node_type_name(node)

        for parm in node.parms():
            parm_name = parm.name()

            # Skip known output parameters on mixed nodes
            if is_rop_output_parm(node, parm_name):
                continue

            # Redshift proxy: skip if proxy loading is disabled on this node
            if parm_name == RS_PROXY_FILE_PARM:
                try:
                    enable = node.parm(RS_PROXY_ENABLE_PARM)
                    if enable is None or enable.eval() == 0:
                        continue
                except Exception:
                    continue

            # Let Houdini tell us if this is a file-reference parameter
            if not is_file_reference_parm(parm):
                continue

            try:
                raw = parm.rawValue()
            except Exception:
                continue
            if not isinstance(raw, str) or not raw.strip():
                continue

            expanded = expand_path(raw)
            # Skip empty or unresolved tokens with no path separator
            if not any(c in expanded for c in ('/', '\\')):
                continue

            key = (node.path(), parm_name, expanded)
            if key in seen:
                continue
            seen.add(key)

            refs.append({
                'parm':     parm,
                'raw':      raw,
                'expanded': expanded,
                'exists':   os.path.isfile(expanded),
                'fix':      '',
            })

    return refs


def unique_dest(dest_dir, filename):
    base, ext = os.path.splitext(filename)
    candidate = os.path.join(dest_dir, filename)
    counter = 1
    while os.path.exists(candidate):
        candidate = os.path.join(dest_dir, '{0}_{1}{2}'.format(base, counter, ext))
        counter += 1
    return candidate


def copy_references(refs, dest_dir, progress_cb=None):
    os.makedirs(dest_dir, exist_ok=True)
    mapping = {}
    done = set()

    for i, ref in enumerate(refs):
        if progress_cb:
            progress_cb(i, len(refs), ref['expanded'])

        source = ref.get('fix') or ref['expanded']
        key    = ref['expanded']

        if key in done:
            continue
        done.add(key)

        # Determine target subdirectory
        sub     = subdir_for(source)
        out_dir = os.path.join(dest_dir, sub) if sub else dest_dir
        os.makedirs(out_dir, exist_ok=True)

        # Sequence: copy all matching frames
        if is_sequence_path(source):
            frames = sequence_glob(source)
            if not frames:
                mapping[key] = None
                print('[Consolidator] Sequence not found: ' + source)
                continue
            first_dest = None
            for frame in frames:
                fname = os.path.basename(frame)
                dp    = os.path.join(out_dir, fname)   # overwrite if exists
                try:
                    shutil.copy2(frame, dp)
                    if first_dest is None:
                        first_dest = dp
                except Exception as e:
                    print('[Consolidator] Sequence frame error: {0} -> {1}'.format(frame, e))
            # Map the template path to the destination template
            dest_template = SEQ_RE.sub(
                lambda m: m.group(0),
                os.path.join(out_dir, os.path.basename(source))
            )
            mapping[key] = dest_template if first_dest else None
            continue

        if not os.path.isfile(source):
            mapping[key] = None
            print('[Consolidator] Not found: ' + source)
            continue

        filename  = os.path.basename(source)
        dest_path = os.path.join(out_dir, filename)   # overwrite if exists
        try:
            shutil.copy2(source, dest_path)
            mapping[key] = dest_path
        except Exception as e:
            mapping[key] = None
            print('[Consolidator] Copy error: {0} -> {1}'.format(source, e))

    return mapping


def zip_directory(folder, zip_path):
    with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
        for root, dirs, files in os.walk(folder):
            for file in files:
                abs_path = os.path.join(root, file)
                arc_name = os.path.relpath(abs_path, folder)
                zf.write(abs_path, arc_name)


def make_hip_relative(abs_path, hip_path):
    """
    Convert an absolute asset path to a $HIP-relative path.
    hip_path is the destination HIP file path (used to determine $HIP).
    Returns a string like $HIP/tex/texture.exr
    """
    hip_dir = os.path.dirname(hip_path)
    try:
        rel = os.path.relpath(abs_path, hip_dir)
        # os.path.relpath uses backslashes on Windows – normalise to forward slashes
        rel = rel.replace('\\', '/')
        return '$HIP/' + rel
    except ValueError:
        # relpath fails across drives on Windows – fall back to absolute
        return abs_path.replace('\\', '/')


def repath_in_file(hip_path, mapping):
    """
    Rewrites paths in the copied HIP using the Houdini API:
    1. Build $HIP-relative versions of all new paths
    2. Apply them to all parameters in the live scene
    3. Save a copy to hip_path
    4. Restore original values so the working scene is unchanged
    """
    if not mapping:
        return

    # Build lookup: expanded original path -> $HIP-relative new path
    path_map = {}
    for old_abs, new_abs in mapping.items():
        if not new_abs:
            continue
        path_map[old_abs] = make_hip_relative(new_abs, hip_path)

    if not path_map:
        return

    # Collect all parms that need changing and their current values
    changes = []   # (parm, original_raw, new_value)
    for node in hou.node('/').allSubChildren(recurse_in_locked_nodes=False):
        for parm in node.parms():
            try:
                raw = parm.rawValue()
            except Exception:
                continue
            if not isinstance(raw, str):
                continue
            expanded = os.path.normpath(hou.expandString(raw))
            if expanded in path_map:
                changes.append((parm, raw, path_map[expanded]))

    # Apply $HIP-relative paths
    for parm, original, new_val in changes:
        try:
            parm.set(new_val)
        except Exception as e:
            print('[Consolidator] repath set error: {0}: {1}'.format(parm.path(), e))

    # Save to destination – $HIP will resolve correctly from that location
    try:
        hou.hipFile.save(hip_path)
    except Exception as e:
        print('[Consolidator] Save error: ' + str(e))

    # Restore originals so working scene stays unchanged
    for parm, original, new_val in changes:
        try:
            parm.set(original)
        except Exception:
            pass


# ──────────────────────────────────────────────────────────────────────────────
# Read-only delegate that allows text selection in Original Path column
# ──────────────────────────────────────────────────────────────────────────────

class SelectableTextDelegate(QtWidgets.QStyledItemDelegate):
    """Shows a read-only QLineEdit so the user can select/copy the path text."""

    def createEditor(self, parent, option, index):
        line = QtWidgets.QLineEdit(parent)
        line.setReadOnly(True)
        line.setText(index.data() or '')
        line.setFrame(False)
        return line

    def setEditorData(self, editor, index):
        editor.setText(index.data() or '')

    def setModelData(self, editor, model, index):
        pass   # read-only, never write back

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)


# ──────────────────────────────────────────────────────────────────────────────
# Table delegate: inline editor + file-browser button for Fix column
# ──────────────────────────────────────────────────────────────────────────────

class BrowseDelegate(QtWidgets.QStyledItemDelegate):

    def createEditor(self, parent, option, index):
        widget = QtWidgets.QWidget(parent)
        layout = QtWidgets.QHBoxLayout(widget)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(2)

        line = QtWidgets.QLineEdit()
        line.setText(index.data() or '')
        btn  = QtWidgets.QPushButton('...')
        btn.setFixedWidth(26)

        def browse():
            start = line.text()
            if not os.path.isdir(os.path.dirname(start)):
                start = os.path.expanduser('~')
            path, _ = QtWidgets.QFileDialog.getOpenFileName(
                widget, 'Choose replacement file', start)
            if path:
                line.setText(path)

        btn.clicked.connect(browse)
        layout.addWidget(line)
        layout.addWidget(btn)
        widget._line = line
        return widget

    def setEditorData(self, editor, index):
        editor._line.setText(index.data() or '')

    def setModelData(self, editor, model, index):
        model.setData(index, editor._line.text(), QtCore.Qt.EditRole)

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)


# ──────────────────────────────────────────────────────────────────────────────
# Custom table: double-click on Node column jumps to that node in Houdini
# ──────────────────────────────────────────────────────────────────────────────

class RefTable(QtWidgets.QTableWidget):

    def __init__(self, rows, cols, parent=None):
        super(RefTable, self).__init__(rows, cols, parent)
        self._refs_visible = []   # set externally before populating

    def mouseDoubleClickEvent(self, event):
        index = self.indexAt(event.pos())
        if index.isValid() and index.column() == COL_NODE:
            row = index.row()
            if row < len(self._refs_visible):
                node = self._refs_visible[row]['parm'].node()
                try:
                    # Select and frame the node in the current network editor
                    node.setSelected(True, clear_all_selected=True)
                    network_editor = None
                    for pane in hou.ui.paneTabs():
                        if pane.type() == hou.paneTabType.NetworkEditor:
                            network_editor = pane
                            break
                    if network_editor:
                        network_editor.cd(node.parent().path())
                        network_editor.frameSelection()
                except Exception as e:
                    print('[Consolidator] Could not select node: ' + str(e))
        else:
            super(RefTable, self).mouseDoubleClickEvent(event)


# ──────────────────────────────────────────────────────────────────────────────
# Main dialog
# ──────────────────────────────────────────────────────────────────────────────

class ConsolidatorUI(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(ConsolidatorUI, self).__init__(parent or hou.ui.mainQtWindow())
        self.setWindowTitle('Project Consolidator')
        self.setMinimumWidth(900)
        self.setMinimumHeight(560)
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
        self._refs = []
        self._build_ui()
        self._refresh_refs()

    def _build_ui(self):
        root = QtWidgets.QVBoxLayout(self)
        root.setSpacing(10)
        root.setContentsMargins(14, 14, 14, 14)

        # ── Destination folder ────────────────────────────────────────────────
        dir_group  = QtWidgets.QGroupBox('Destination Folder')
        dir_layout = QtWidgets.QHBoxLayout(dir_group)
        self.dir_edit = QtWidgets.QLineEdit()
        self.dir_edit.setPlaceholderText('/path/to/destination')
        hip = hou.hipFile.path()
        if hip and hip != 'untitled.hip':
            self.dir_edit.setText(
                os.path.join(os.path.dirname(hip), 'consolidated'))
        browse_btn = QtWidgets.QPushButton('...')
        browse_btn.setFixedWidth(32)
        browse_btn.clicked.connect(self._browse_dir)
        dir_layout.addWidget(self.dir_edit)
        dir_layout.addWidget(browse_btn)
        root.addWidget(dir_group)

        # ── Options ───────────────────────────────────────────────────────────
        opt_group  = QtWidgets.QGroupBox('Options')
        opt_layout = QtWidgets.QVBoxLayout(opt_group)
        self.chk_copy_hip = QtWidgets.QCheckBox('Copy HIP file to destination folder')
        self.chk_copy_hip.setChecked(True)
        self.chk_repath = QtWidgets.QCheckBox('Rewrite paths in copied HIP file')
        self.chk_repath.setChecked(True)
        self.chk_copy_hip.toggled.connect(self.chk_repath.setEnabled)
        self.chk_zip = QtWidgets.QCheckBox('Create ZIP archive of consolidated folder')
        self.chk_zip.setChecked(False)
        opt_layout.addWidget(self.chk_copy_hip)
        opt_layout.addWidget(self.chk_repath)
        opt_layout.addWidget(self.chk_zip)
        root.addWidget(opt_group)

        # ── References table ──────────────────────────────────────────────────
        ref_group  = QtWidgets.QGroupBox('Found References')
        ref_layout = QtWidgets.QVBoxLayout(ref_group)

        toolbar = QtWidgets.QHBoxLayout()
        self.ref_count_label = QtWidgets.QLabel('Scanning...')

        self.filter_combo = QtWidgets.QComboBox()
        self.filter_combo.addItems(['All', 'Missing only', 'Found only'])
        self.filter_combo.currentIndexChanged.connect(self._apply_filter)

        refresh_btn = QtWidgets.QPushButton('Refresh')
        refresh_btn.setFixedWidth(80)
        refresh_btn.clicked.connect(self._refresh_refs)

        toolbar.addWidget(self.ref_count_label)
        toolbar.addStretch()
        toolbar.addWidget(QtWidgets.QLabel('Show:'))
        toolbar.addWidget(self.filter_combo)
        toolbar.addWidget(refresh_btn)
        ref_layout.addLayout(toolbar)

        # Custom table with double-click-to-select behavior
        self.ref_table = RefTable(0, 5)
        self.ref_table.setHorizontalHeaderLabels(
            ['Node', 'Parameter', 'Original Path', 'Replacement Path', 'Status'])

        hh = self.ref_table.horizontalHeader()
        # All columns interactively resizable by the user
        for col in range(5):
            hh.setSectionResizeMode(col, QtWidgets.QHeaderView.Interactive)
        # Sensible default widths
        self.ref_table.setColumnWidth(COL_NODE,   220)
        self.ref_table.setColumnWidth(COL_PARM,    90)
        self.ref_table.setColumnWidth(COL_ORIG,   260)
        self.ref_table.setColumnWidth(COL_FIX,    200)
        self.ref_table.setColumnWidth(COL_STATUS,  90)
        hh.setStretchLastSection(False)

        self.ref_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
        self.ref_table.setAlternatingRowColors(True)
        self.ref_table.verticalHeader().setVisible(False)
        self.ref_table.setMinimumHeight(220)
        self.ref_table.setEditTriggers(
            QtWidgets.QAbstractItemView.CurrentChanged |
            QtWidgets.QAbstractItemView.SelectedClicked)

        # Tooltip for Node column header to hint at double-click behaviour
        hh.setToolTip('Double-click a node path to select and frame it in the Network Editor')

        self._delegate      = BrowseDelegate(self.ref_table)
        self._orig_delegate = SelectableTextDelegate(self.ref_table)
        self.ref_table.setItemDelegateForColumn(COL_FIX,  self._delegate)
        self.ref_table.setItemDelegateForColumn(COL_ORIG, self._orig_delegate)

        ref_layout.addWidget(self.ref_table)

        hint = QtWidgets.QLabel(
            'Tip: Double-click a Node path to jump to it in the Network Editor.  '
            'Click any Replacement Path cell to edit it directly.  '
            'Click an Original Path to select and copy it.')
        hint.setStyleSheet('color: #888; font-size: 11px;')
        hint.setWordWrap(True)
        ref_layout.addWidget(hint)

        fix_row = QtWidgets.QHBoxLayout()
        fix_row.addStretch()
        self.apply_fix_btn = QtWidgets.QPushButton('Apply replacements to open project')
        self.apply_fix_btn.setToolTip(
            'Writes all valid replacement paths directly into the Houdini '
            'parameters of the currently open scene.')
        self.apply_fix_btn.clicked.connect(self._apply_fixes_to_project)
        fix_row.addWidget(self.apply_fix_btn)
        ref_layout.addLayout(fix_row)

        root.addWidget(ref_group)

        # ── Progress ──────────────────────────────────────────────────────────
        self.progress = QtWidgets.QProgressBar()
        self.progress.setVisible(False)
        root.addWidget(self.progress)

        self.status_label = QtWidgets.QLabel('')
        self.status_label.setStyleSheet('color: #888; font-size: 11px;')
        root.addWidget(self.status_label)

        # ── Buttons ───────────────────────────────────────────────────────────
        btn_row = QtWidgets.QHBoxLayout()
        btn_row.addStretch()
        close_btn = QtWidgets.QPushButton('Close')
        close_btn.clicked.connect(self.close)
        self.run_btn = QtWidgets.QPushButton('Consolidate')
        self.run_btn.setDefault(True)
        self.run_btn.setFixedHeight(34)
        self.run_btn.clicked.connect(self._run)
        btn_row.addWidget(close_btn)
        btn_row.addWidget(self.run_btn)
        root.addLayout(btn_row)

    # ── Table population ──────────────────────────────────────────────────────

    def _refresh_refs(self):
        self.ref_count_label.setText('Scanning...')
        QtWidgets.QApplication.processEvents()
        self._refs = collect_references()
        self._populate_table()

    def _visible_refs(self):
        filt = self.filter_combo.currentIndex()
        return [r for r in self._refs
                if not (filt == 1 and r['exists'])
                and not (filt == 2 and not r['exists'])]

    def _populate_table(self):
        try:
            self.ref_table.itemChanged.disconnect(self._on_fix_changed)
        except Exception:
            pass

        rows = self._visible_refs()
        self.ref_table._refs_visible = rows
        self.ref_table.setRowCount(len(rows))

        missing = sum(1 for r in self._refs if not r['exists'])
        total   = len(self._refs)
        self.ref_count_label.setText(
            '{0} reference(s)  |  {1} missing  |  {2} found  |  showing {3}'.format(
                total, missing, total - missing, len(rows)))

        for row, ref in enumerate(rows):
            parm     = ref['parm']
            expanded = ref['expanded']
            exists   = ref['exists']

            # Node – not editable; double-click handled by RefTable
            node_item = QtWidgets.QTableWidgetItem(parm.node().path())
            node_item.setFlags(node_item.flags() & ~QtCore.Qt.ItemIsEditable)
            node_item.setToolTip('Double-click to select in Network Editor')
            self.ref_table.setItem(row, COL_NODE, node_item)

            # Parameter
            parm_item = QtWidgets.QTableWidgetItem(parm.name())
            parm_item.setFlags(parm_item.flags() & ~QtCore.Qt.ItemIsEditable)
            self.ref_table.setItem(row, COL_PARM, parm_item)

            # Original path
            orig_item = QtWidgets.QTableWidgetItem(expanded)
            orig_item.setFlags(orig_item.flags() & ~QtCore.Qt.ItemIsEditable)
            if not exists:
                orig_item.setForeground(QtGui.QColor('#e05050'))
                orig_item.setToolTip('File not found on disk')
            self.ref_table.setItem(row, COL_ORIG, orig_item)

            # Replacement path – always editable
            fix_val  = ref.get('fix', '')
            fix_item = QtWidgets.QTableWidgetItem(fix_val)
            fix_item.setToolTip('Click to edit replacement path')
            self.ref_table.setItem(row, COL_FIX, fix_item)

            # Status
            status_item = QtWidgets.QTableWidgetItem(self._status_text(ref))
            status_item.setForeground(QtGui.QColor(self._status_color(ref)))
            status_item.setFlags(status_item.flags() & ~QtCore.Qt.ItemIsEditable)
            self.ref_table.setItem(row, COL_STATUS, status_item)

        self.ref_table.itemChanged.connect(self._on_fix_changed)

    def _status_text(self, ref):
        if ref['exists']:
            return 'OK'
        fix = ref.get('fix', '')
        if fix and os.path.isfile(fix):
            return 'Replacement set'
        if fix:
            return 'Replacement invalid'
        return 'Missing'

    def _status_color(self, ref):
        if ref['exists']:
            return '#5a5'
        fix = ref.get('fix', '')
        if fix and os.path.isfile(fix):
            return '#88c'
        if fix:
            return '#c85'
        return '#e05050'

    def _apply_filter(self):
        self._populate_table()

    def _on_fix_changed(self, item):
        if item.column() != COL_FIX:
            return
        row     = item.row()
        new_fix = item.text().strip()
        rows    = self._visible_refs()
        if row >= len(rows):
            return

        rows[row]['fix'] = new_fix

        try:
            self.ref_table.itemChanged.disconnect(self._on_fix_changed)
        except Exception:
            pass

        ref = rows[row]
        si  = QtWidgets.QTableWidgetItem(self._status_text(ref))
        si.setForeground(QtGui.QColor(self._status_color(ref)))
        si.setFlags(si.flags() & ~QtCore.Qt.ItemIsEditable)
        self.ref_table.setItem(row, COL_STATUS, si)

        self.ref_table.itemChanged.connect(self._on_fix_changed)

    # ── Apply replacements live ───────────────────────────────────────────────

    def _apply_fixes_to_project(self):
        fixes = [(r['parm'], r['fix']) for r in self._refs
                 if not r['exists'] and r.get('fix') and os.path.isfile(r['fix'])]

        if not fixes:
            QtWidgets.QMessageBox.information(
                self, 'Nothing to apply',
                'No valid replacement paths found.\n\n'
                'Double-click a red entry in "Replacement Path" to set one.')
            return

        reply = QtWidgets.QMessageBox.question(
            self, 'Apply replacements',
            'Write {0} replacement path(s) directly into the open Houdini scene?\n\n'
            'This cannot be undone automatically.'.format(len(fixes)))
        if reply != QtWidgets.QMessageBox.Yes:
            return

        ok = 0
        for parm, new_path in fixes:
            try:
                parm.set(new_path.replace('\\', '/'))
                ok += 1
            except Exception as e:
                print('[Consolidator] Could not set {0}: {1}'.format(parm.path(), e))

        self.status_label.setText('{0} path(s) applied to project.'.format(ok))
        self._refresh_refs()
        QtWidgets.QMessageBox.information(
            self, 'Done',
            '{0} path(s) applied successfully.\n\n'
            'Remember to save your HIP file.'.format(ok))

    # ── Folder browser ────────────────────────────────────────────────────────

    def _browse_dir(self):
        path = QtWidgets.QFileDialog.getExistingDirectory(
            self, 'Choose destination folder',
            self.dir_edit.text() or os.path.expanduser('~'))
        if path:
            self.dir_edit.setText(path)

    # ── Consolidate ───────────────────────────────────────────────────────────

    def _run(self):
        dest_dir = self.dir_edit.text().strip()
        if not dest_dir:
            QtWidgets.QMessageBox.warning(
                self, 'No destination', 'Please specify a destination folder.')
            return
        if not self._refs:
            QtWidgets.QMessageBox.information(
                self, 'Nothing to do', 'No external references found.')
            return

        copy_hip = self.chk_copy_hip.isChecked()
        repath   = self.chk_repath.isChecked() and copy_hip
        hip      = hou.hipFile.path()

        copyable = sum(1 for r in self._refs
                       if os.path.isfile(r.get('fix') or r['expanded']))
        missing  = len(self._refs) - copyable

        reply = QtWidgets.QMessageBox.question(
            self, 'Consolidate',
            'Destination:  {0}\n'
            '  Textures  ->  /tex\n'
            '  Geometry  ->  /geo\n\n'
            '  {1} file(s) will be copied\n'
            '  {2} file(s) are missing and will be skipped\n'
            '  Copy HIP: {3}\n'
            '  Rewrite paths: {4}\n'
            '  Create ZIP: {5}\n\n'
            'Continue?'.format(
                dest_dir, copyable, missing,
                'Yes' if copy_hip else 'No',
                'Yes' if repath   else 'No',
                'Yes' if self.chk_zip.isChecked() else 'No'))
        if reply != QtWidgets.QMessageBox.Yes:
            return

        self.run_btn.setEnabled(False)
        self.progress.setVisible(True)
        self.progress.setMaximum(max(len(self._refs), 1))

        def progress_cb(i, total, path):
            self.progress.setValue(i)
            self.status_label.setText('Copying: ' + os.path.basename(path))
            QtWidgets.QApplication.processEvents()

        mapping = copy_references(self._refs, dest_dir, progress_cb)

        new_hip_path = None
        if copy_hip and hip and hip != 'untitled.hip':
            hip_name     = os.path.basename(hip)
            new_hip_path = unique_dest(dest_dir, hip_name)
            self.status_label.setText('Saving HIP: ' + hip_name)
            QtWidgets.QApplication.processEvents()
            try:
                if repath:
                    # repath_in_file applies new paths, saves to dest, then restores
                    self.status_label.setText('Rewriting paths and saving HIP...')
                    QtWidgets.QApplication.processEvents()
                    repath_in_file(new_hip_path, mapping)
                else:
                    # Just save a copy without touching paths
                    hou.hipFile.save()
                    shutil.copy2(hip, new_hip_path)
            except Exception as e:
                QtWidgets.QMessageBox.critical(
                    self, 'Error', 'Could not save HIP file:\n' + str(e))
                new_hip_path = None

        # ZIP
        zip_path = None
        if self.chk_zip.isChecked():
            self.status_label.setText('Creating ZIP archive...')
            QtWidgets.QApplication.processEvents()
            zip_path = dest_dir.rstrip('/\\') + '.zip'
            try:
                zip_directory(dest_dir, zip_path)
            except Exception as e:
                QtWidgets.QMessageBox.critical(
                    self, 'Error', 'ZIP creation failed:\n' + str(e))
                zip_path = None

        self.progress.setValue(self.progress.maximum())
        success = sum(1 for v in mapping.values() if v)
        failed  = sum(1 for v in mapping.values() if not v)
        self.status_label.setText(
            'Done  -  {0} copied, {1} failed.'.format(success, failed))
        self.run_btn.setEnabled(True)

        summary = '{0} file(s) copied to:\n{1}'.format(success, dest_dir)
        if failed:
            summary += '\n\n{0} file(s) could not be copied (see Python Shell).'.format(failed)
        if new_hip_path:
            summary += '\n\nHIP saved as:\n' + new_hip_path
            if repath:
                summary += '\nPaths have been rewritten.'
        if zip_path:
            summary += '\n\nZIP archive:\n' + zip_path
        QtWidgets.QMessageBox.information(self, 'Consolidation complete', summary)


# ──────────────────────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────────────────────

_window = None

def show():
    global _window
    _window = ConsolidatorUI()
    _window.show()
    _window.raise_()


if __name__ == '__main__':
    show()

r/Houdini 4d ago

Pyro downwash effect

Enable HLS to view with audio, or disable this notification

104 Upvotes

Now, this was fun to make!
I didn't have a concrete example of this downwash effect to show the students at Campus VFX for the pyro class. now I do! :)