Currently it runs on the CPU & will generate its own primitive scene proxy to calculate the mesh on the render thread from the particle simulation. It also collides with the scene in real time – works a lot better than I was expecting!
I am contemplating releasing this as a plugin for the marketplace, until then here are my plans:
Improve stability (ensure assert conditions won’t be hit as much as possible)
Double-check performance & provide CPU profiling stats akin to the CableComponent plugin
I am a technical artist currently working in AAA. At the time of this post I’m working at The Multiplayer Guys (Improbable), and previously I worked at Sumo Digital, and as a freelance technical/vfx artist on various AAA & indie projects on a variety of platforms (mobile, VR, console, PC). I also have a strong background in 3d art.What is this blog for?
I got tired of making tools, shaders, VFX etc in a vaccum and forgetting to share it outside of my circles! So I want to share what I learn and make publicly for people. *Not affiliated with my current employer in any way
I also want to be able to provide a much heavier and more technical insight into topics people maybe don’t cover related to tech art, or maybe get left at the wayside in the art circle!
Also who knows? Maybe I will be able to share some of my work on titles I have worked on after their release.. (big maybe no promises)
What this blog is not
This is not my portfolio. Clean and simple. If you want to see actual 3d art, I post that here (mainly for fun) – artstation.com/calvinatorr Though I don’t do 3d art 97% of the time in my day-to-day duties, I guess I am still (technically) an artist! I still find it extremely important to remain adept at 3d art processes and even some traditional.
This is also not a place where I will post my cat pictures.. after this one.. okay no more!
I recently integrated Epic’s cloth shading model (inverse GGX and Ashkimin) into the Substance Designer shader I written a few months ago. You can grab it here – github.com/Calvinatorr/SubstanceShaderUE
I integrated the logic of the inputs to parallel Epic’s for parity (i.e. Fuzz Colour = F0 when Metallic=1).
Vanilla shader vs Modified shader (Substance Designer viewport)Render from Unreal Engine 4.25 (no post) for comparison (lighting setup is different)
Inputs
What each input does:
Base Colour (Albedo) – Determines the diffuse colour. Metal=1 will blend the albedo to black
Roughness – Controls the falloff of the rimlight effect (this is actually the specular reflection). Avoid low values where cloth appears glossy. Higher values make cloth appear thick.
Metallic
Cloth – Controls the BRDF; 0=microfacet (default shading), 1=cloth BRDF
Fuzz Colour – Reflectance/F0/Specular Colour of the cloth. When metal=1, the reflectance is still the Fuzz Colour, not the Albedo like in the microfacet model!
Map inputs (Cloth=Mask, Fuzz Colour=Tramissive)Use the cloth input (0 or 1) to blend between BRDFs just like Epic’s model
Sample Graph
A very basic sample graph has been provided to view how the inputs can be setup.Comes bundled with a cloth mesh for best viewing the viewport shading
Recommendations
Use the directional light & disable IBL (3D View>[material]>Edit) to best see how light will interact.
Balance the albedo & fuzz colour.
Use roughness values above 0.3. Mid value ranges (0.4-0.8 seem to work best).
The aim of this tool is to provide a more accurate representation with the Unreal Engine 4 viewport by providing a pixel shader which uses BRDFs closer/pulled from Unreal’s shaders, as well ACES tonemapping.
IBL samples slider (default changed from 16 to 64)
BRDFs more accurate to Unreal (Fresnel Schlick approximation, GGX Smith) – light source calculation & IBL
Options added in the material properties menu
Comparison
I compared the same input parameters against Substance Designer vanilla, my shader, and the UE4 viewport. Those being albedo=(.75,.75,.75), roughness = .25, and metallic=1.
The results I got still aren’t 1-1 as there’s a lot of complex stuff going on Unreal, especially with an adjustable log curve for the tonemapping, but generally I feel this is good enough to at least have a better idea of what your material will look like across packages.
I’m continuing to review the BRDFs and colour transform as I use the shader myself and will update the git repo as I go.
I’ve already investigated doing the same for Substance Painter, but unfortunately Painter’s renderer is more complicated (seemingly deferred), compared to Designer which seems to be done in forward. This means in Designer I can touch the final pixel colour as I wish (there is some still some post effects which can be enabled after this in the pipeline), which means I can directly control the shading BRDFs.
Painter seemingly provides shaders more as material shaders, to provide a way to program the inputs into an already existing shading model which I can’t touch (think Unreal’s material graph).
For this exercise I written a voronoi noise function, also known as cell noise, or worley noise. If you want to use it you can grab it from here – just paste it into a material function.
The noise pattern ends up looking like this, which essentially represents a distance field to a set of points randomly scattered
Though I’m still not happy with the distribution of points (as you can see below) isn’t random enough – this can be tweaked from a set point in the code, but for now I got what I needed from this.
And this method can be used for more than just “here’s some random noise” if approached from the perspective of representing a set of randomly scattered points!
I’m leveraging some methods from Unreal’s library of random noise algorithms which you can include in HLSL of your custom node. You can include any of the of the engine HLSL files in a custom node by using a path relative to /Engine/, for example:
#include "/Engine/Private/Random.ush"
The final output tallies up to 52 instructions according to Unreal’s material editor stats.
This is in contrast to Unreal’s VectorNoise atomic node which also gives you the closest point (RGB), and the distance (A). Just using the distance from the VectorNoise node will cost you a whopping 162 instructions!
Base pass 194 instructions total. Subtract baseline cost of 32 instructions as of UE4.23. Base pass 84 instructions total. Subtract baseline cost of 32 instructions as of UE4.23.
Code (HLSL)
This is the code inside my HLSL function which does all the work.
The basic premise is I calculate which cell I’m currently at, generate an (squared) SDF (signed distance field) for a point randomly generated inside the cell, and combine it with the currently accumulated distance field (using min()). I do the same for each neighbouring cell, so
#include "/Engine/Private/Random.ush"
float AccumDist = 1;
for ( int x = -1; x <= 1; x++ )
{
for ( int y = -1; y <= 1; y++ )
{
for ( int z = -1; z <= 1; z++ )
{
float3 CurrentCell = floor(UV) + float3(x, y, z); // Find neighbouring UV or current (when x,y == 0)
// Get random point inside cell
float2 Seedf2 = float2(Seed, Seed);
float3 Rand = float3(PseudoRandom(CurrentCell.xy + Seedf2), PseudoRandom(CurrentCell.xz + Seedf2), PseudoRandom(CurrentCell.yz + Seedf2));
float3 Point = frac(Rand) + CurrentCell;
float3 Dir = Point - UV; // Generate vector
float Dist = dot(Dir, Dir); // Get distance squared
AccumDist = min(AccumDist, Dist); // Combine with current signed distance field (sdf)
}
}
}
return AccumDist; // sqrt() this to get distance
Some notes I think are important to note from this are:
UNROLL macro – I’m not using this, instead I’m letting Unreal’s HLSL compiler decide as it usually does a good job. In this case it chose to not unroll my loops – this is most likely as the branch is non-divergent which allows the GPU to not stall when processing a branch due to how GPUs process pixel groups in parallel
If you chose to use any of the branching macros ([flatten], [branch], [unroll] etc) you should use Unreal’s macros (caps, remove []) as you’ll avoid cook errors when compiling to PSGL for PS4 for example.
I am accumulating a signed distance field as distance squared. A common trick to do this is to get the dot product of a vector with itself to return the length squared of that vector! I’m doing this because using distance() or length() will involve a sqrt(), which is an expensive operation to perform, especially if we’re doing this a lot. So instead I am working in distance squared, and then I sqrt() I’ve executed my loops – in-fact I do this in atomic nodes to work best with my material function set-up, in-case I do want to use distance squared.
Profiling
I initially started my profiling just using gpu stat in Unreal. It showed me at fullscreen (approx) 1920×1080 with a quad with my shader applied it was .35ms on the base pass. Annoyingly this was no different than a regular unlit shader.
Fullscreen (approx) 1080p with just unlit quad with shader applies costs approx .35ms base pass according to Unreal’s profiler – same as a regular unlit material.
So I compared the baseline (quad + unlit shader) with my shader in renderdoc. On average I saw about 300uS cost, so I’ll chalk that up to this costing around about .3ms on GPU if this is calculated fullscreen. Sounds about right (still not terribly accurate though).
I also compared (not shown below) the atomic VectorNoise.a method which yielded an average of 850us base pass (.35ms cost), so by the tiniest amount more expernsive.
A win, I suppose? I guess it at least proves there is no huge overhead from my branching in this case.
I developed this tool because the current import FBX sop isn’t very procedural as it’s only accessed through a menu (though you can call it from Python, which is what I am doing essentially).
Said tool generates an objnet consisting of a series of nodes which represents the hierarchy of the FBX – this can be a pain to work with though, especially compared to the File sop which just pulls in geo as is.
This tool just wraps up that tool in a sop context and pulls all references in to the local HDA level, and builds the subsequent networks to output and organsie the geometry.
How it works
As this tool is just a sop wrapper which calls the FBX API already exposed, it should be maintainable going forward as all the heavy lifting is already done.
This is also useful as it builds the necessary matnets, transforms, hiearchies etc which I can utilise – I just move this whole objnet to an editable objnet inside my HDA context. My tool then builds 2 objmerge networks to pull in the geo from the objnet.
For example this subway train has a load of submeshes & material IDs which you can see in this hiearchyThe Load FBX sop will build the objmerge networks in these subnets and output them (including groups and attributes), pretty simpleThe actual network looks like this.. and unfortunately you can’t just use a single objmerge to combine all these nodesAnd for the unaware – you can define Editable Nodes in your HDA. This means even when the HDA is locked you can change the state of those nodes! Thanks Luiz Kruel for pointing me to this, I felt pretty dumb when I realised this is of course what they are for!Subsequently, the user only has to see this – simple! If they want to reload the geometry, they can just call pressButton() on the button param. I’ve even exposed the collision net as guide geo – neat!sop level access & parameter panel Can be embedded into other HDAs just like a file sop
All the work in this tool is in the Python module attached to the HDA, where the LoadFBX() method is called by a callback on the load button callback, and the text field callback.
I’m doing this with this very simple line to be able to call my methods from a Py module on the current node context we’re executing from.
hou.phm().LoadFBX()
And this is the Python module. Edit 06/01/2020: Updated with import transform functionality, vertex colour preservation (prevent mismatch of Cd attribute), added ClearGeometry() method, & added suite of import options
import os, sys
def ClearNetwork(network):
for child in network.children():
child.destroy()
def SetupMergeNetwork(network):
result = {"network":network}
ClearNetwork(network)
result["merge"] = network.createNode("merge", "merge")
result["merge"].setDisplayFlag(True)
result["merge"].setInput(0, result["network"].indirectInputs()[0])
return result
def ClearGeometry():
with hou.undos.group("Clearing geometry loaded from '" + hou.parm("file").eval() + "'"):
ClearNetwork(hou.node("IMPORT"))
ClearNetwork(hou.node("GEO_MERGE"))
ClearNetwork(hou.node("COLLISION_MERGE"))
def LoadFBX():
# Get file path
file = hou.parm("file").eval() # Evaluate path
file = os.path.normpath(file)
if not os.path.exists(file):
raise hou.NodeError("File doesn't exist")
return
# Find import objnet (editable)
objnet = hou.node("IMPORT")
ClearNetwork(objnet)
# Import FBX
#hou.hscript("fbximport {}".format(file))
materialMode = hou.fbxMaterialMode.FBXShaderNodes if hou.parm("materialMode").eval()==1 else hou.fbxMaterialMode.VopNetworks
compatabilityMode = hou.fbxCompatibilityMode.FBXStandard if hou.parm("compatabilityMode").eval()==1 else hou.fbxCompatibilityMode.Maya
rawFbx = hou.hipFile.importFBX(file,
import_animation=hou.parm("importAnimation").eval(),
import_joints_and_skin=hou.parm("importJointsAndSkin").eval(),
resample_animation=hou.parm("resampleAnimation").eval(),
resample_interval=hou.parm("resampleInterval").eval(),
import_materials=hou.parm("importMaterials").eval(),
material_mode=materialMode,
compatibility_mode=compatabilityMode)
fbx = rawFbx[0].copyTo(objnet)
rawFbx[0].destroy()
# Link import transform
fbx.parm("xOrd").set(hou.parm("xOrd"))
fbx.parm("rOrd").set(hou.parm("rOrd"))
fbx.parm("tx").set(hou.parm("tx"))
fbx.parm("ty").set(hou.parm("ty"))
fbx.parm("tz").set(hou.parm("tz"))
fbx.parm("rx").setExpression('ch("../../rx") + if(ch("../../axis")==1, 90, 0)', language=hou.exprLanguage.Hscript) # Link scale
fbx.parm("ry").set(hou.parm("ry"))
fbx.parm("rz").set(hou.parm("rz"))
fbx.parm("sx").set(hou.parm("sx"))
fbx.parm("sy").set(hou.parm("sy"))
fbx.parm("sz").set(hou.parm("sz"))
fbx.parm("px").set(hou.parm("px"))
fbx.parm("py").set(hou.parm("py"))
fbx.parm("pz").set(hou.parm("pz"))
fbx.parm("prx").set(hou.parm("prx"))
fbx.parm("pry").set(hou.parm("pry"))
fbx.parm("prz").set(hou.parm("prz"))
fbx.parm("scale").setExpression('ch("../../importScale") * ch("../../scale")', language=hou.exprLanguage.Hscript) # Link scale
# Build merge networks
# Clear networks first
geo = SetupMergeNetwork(hou.node("GEO_MERGE"))
collision = SetupMergeNetwork(hou.node("COLLISION_MERGE"))
# Generate objmerge nodes
for child in fbx.children():
if not child.type().name() == "geo":
continue # Skip this iteration
isCollider = any(s in child.name() for s in {"UBX", "UCP", "USP", "UCX"})
mergeNet = collision if isCollider else geo
objMerge = mergeNet["network"].createNode("object_merge", child.name()) # Create objmerge node
# Set-up parameters
objMerge.parm("xformtype").set("local")
objMerge.parm("createprimstring").set(True)
objMerge.parm("pathattrib").set("path")
objMerge.parm("objpath1").set(mergeNet["merge"].relativePathTo(child))
objMerge.parm("createprimgroups").set(True)
objMerge.parm("primgroupprefix").set(objMerge.name() + "_node")
objMerge.parm("suffixfirstgroup").set(False)
mergeNet["merge"].setInput(len(mergeNet["merge"].inputConnections()), objMerge) # Connect to merge
objMerge.moveToGoodPosition()
geo["merge"].moveToGoodPosition()
collision["merge"].moveToGoodPosition()
# Select this node to bring parameter panel back
hou.pwd().setSelected(True, True, True)
What’s next?
FBX Export sop of course!
An FBX rop exists of course, but I would like to simplify the process of building a hiearchy from an objnet level, to a sop level and be data-driven to make proceduralism easier.
I am still thinking of my approach to this, but at the simplest level I believe it’d be at least useful to include a collision pin and allow my sop to build the relevant hiearchy and node names, and perhaps some helper sops to categorise collisions.. we will see!