Hey @alcol68,
so, I spent some time with this today, and both the Xpresso and Layer Shader API drive me nuts every time I have to deal with them. The TLDR is here that both Xpresso and the Layer Shader are irregular in their implementations, especially in regards to how they store and access parameters.
Find below some narrative code which guides you through all of this.
Cheers,
Ferdinand
Result
The setup expects a material to be selected which has a layer shader in its color channel with a singular transform layer in it. It will then create this Xpresso setup which drives the angle of the transform layer with a user data field in the null object holding the Xpresso setup.

edit: lol, now I see that also the first example is working in my own screenshot. I have absolutely no idea why. I would still stay away from this. The layer shader internals are a mess.
Code
"""Explores concepts around driving layer shader parameters from XPresso.
This script assumes that the active material has a layer shader in its color channel. As explained
below, this code assumes the ID of the first parameter of the first layer in the shader, which does
not seem to be entirely deterministic.
When push comes to shove, you might have to comment out #Solution.InternalLayerShaderAccess(...) in
#Solution.main() when it throws errors on your system.
"""
import c4d
import mxutils
from c4d.modules.graphview import GvNodeMaster, GvNode, GvPort, XPressoTag
from mxutils import CheckType
doc: c4d.documents.BaseDocument # The currently active document.
op: c4d.BaseObject | None # The primary selected object in `doc`. Can be `None`.
class Solution:
"""Provides a dummy class so that we can organize our code in a more readable way.
"""
@staticmethod
def main() -> None:
"""Called by Cinema 4D when the script is being executed.
"""
# Get the first material and get its color channel shader, assuming it is a layer shader.
material: c4d.BaseMaterial = CheckType(doc.GetActiveMaterial())
shader: c4d.BaseShader = CheckType(material[c4d.MATERIAL_COLOR_SHADER], c4d.LayerShader)
# Create the XPresso setup used by both examples below and also already add the user data
# field to the null object used by the second example.
null: c4d.BaseObject = CheckType(c4d.BaseObject(c4d.Onull))
doc.InsertObject(null)
# Create the "Angle" user data field on the null object.
bc: c4d.BaseContainer = c4d.GetCustomDataTypeDefault(c4d.DTYPE_REAL)
bc[c4d.DESC_NAME] = "Angle"
bc[c4d.DESC_UNIT] = c4d.DESC_UNIT_DEGREE
null.AddUserData(bc)
null[c4d.ID_USERDATA, 1] = c4d.utils.DegToRad(42.0) # Initial value.
# Create the XPresso setup.
tag: XPressoTag = CheckType(null.MakeTag(c4d.Texpresso))
master: GvNodeMaster = CheckType(tag.GetNodeMaster())
# Call this first example, you might have to comment this out, as it relies on the discussed
# magic numbers.
Solution.InternalLayerShaderAccess(shader, master)
# Call the second example, which uses a Python node to access the layer shader. This will
# always work.
Solution.PythonNodeLayerShaderAccess(shader, null, master)
c4d.EventAdd()
@staticmethod
def InternalLayerShaderAccess(
shader: c4d.BaseShader, master: GvNodeMaster) -> None:
"""Explores the internal structure of the layer shader data type and how it interfaces with
XPresso.
Args:
shader: The layer shader to inspect.
master: The node master of the XPresso tag.
"""
# You claimed on the forum that this ID worked for you for animating the transform angle of
# a layer shader.
#
# c4d.DescID(c4d.DescLevel(c4d.SLA_LAYER_BLEND), c4d.DescLevel(10120, c4d.DTYPE_REAL))
# Even when we ignore the concrete numeric offset and replace it with our own, it still does
# not work for me. Could it be that you used abstracted GeListNode.__set/getitem__ access
# instead, i.e., what the console drag and drop generates? Because there this works for me.
print(shader[c4d.SLA_LAYER_BLEND, 10060])
# The problem with these IDs is that they are not deterministic from a public API point of
# view. What holds true, is that data will placed with a stride of 20 (i.e., with
# BLEND_DATA_STEP_SIZE) and starts in theory at 10000 (BLEND_DATA_OFFSET).
#
# But the elements in a layer then do not follow the symbols exposed in the C++ API for the
# layer abstraction but are rather just sequentially numbered on a per layer type basis. E.g.
# for the transform layer, a parameter set event looks like this:
#
# void BlendEffectTransform::SetParameter(Int32 lItem, const DescID &id, ...)
# {
# if (lItem == 0)
# {
# m_rAngle = t_data.GetFloat();
# flags |= DESCFLAGS_SET::PARAM_SET;
# }
# else if (lItem == 1)
# {
# m_bMirror = t_data.GetInt32() != 0;
# flags |= DESCFLAGS_SET::PARAM_SET;
# }
# ...
#
# Where in the API the angle has actually the id LAYER_S_PARAM_TRANS_ANGLE (2000) and the
# mirror has the id LAYER_S_PARAM_TRANS_MIRROR (2001). So, the internal values 0 and 1 are
# hardcoded and absolutely ignore the public API symbols. We can of course make here the
# observation that PUBLIC_SYMBOL - 2000 == INTERNAL_INDEX, but there is no guarantee at all
# that this will always hold true.
#
# The bigger problem is that the the layer start index seems to be somewhat arbitrary. You
# normally would expect that the first layer starts at 10000 (because BLEND_DATA_OFFSET and
# that is what the code suggests), but this does not hold true. When I added a single
# transform layer, its angle parameter had most of the time the ID 10060, i.e., this first
# layer seems to start at what would be the third slot. Once I have also seen it starting at
# 10040 without having a good explanation why.
# So, internally, the layer shader is a bit irregular, or at least more complex than it looks
# like. But let's continue for now and accept these magic numbers.
# To more formally define the DescID for this parameter, we need to define its DescID. For
# that we need the data type of SLA_LAYER_BLEND. It is not exposed as a symbol in the public
# API (but you could look up its numeric value in the description of a layer shader). So, we
# define it here.
CUSTOMDATA_BLEND_LIST: int = 1011131
# Now we can define the DescID for the transform angle parameter of the first layer. This
# assumes that it uses the magic number 10060 for the first property of the first layer and
# that it is of type float. If it is a transform layer or not does not really matter.
did: c4d.DescID = c4d.DescID(
# First desc level is the datatype unexposed to the public API (both C++ and Python)
c4d.DescLevel(
c4d.SLA_LAYER_BLEND, # The parameter ID for the layer blend list.
CUSTOMDATA_BLEND_LIST, # Its data type.
shader.GetType()), # And the owner type of the parameter, i.e., the layer shader.
# The sub-channel of the parameter, i.e., we reach here into the internals of the
# unexposed data type.
c4d.DescLevel(
10060, # Our magic number for the first property of the first layer.
c4d.DTYPE_REAL, # The angle is a real number, you got that absolutely right.
0)) # For sub-channels, the owner type is usually 0.
# With this DescID, we can now access the parameter value formally.
if not shader.SetParameter(did, c4d.utils.DegToRad(30), c4d.DESCFLAGS_SET_NONE):
raise RuntimeError("Could not set parameter value.")
angle: float | None = shader.GetParameter(did, c4d.DESCFLAGS_GET_NONE)
if angle is None:
raise RuntimeError("Could not get parameter value.")
print(f"{angle = }")
# The bad news is that all this will not work in XPresso. The code below will run and create
# a port but the port is absolutely garbage, as it is a "color profile" port. But I had a
# look at how the C++ XPresso code creates the ports for #ID_OPERATOR_OBJECT nodes and there
# is custom logic also covering layer shaders that is not replicable in the public API
# (neither C++ nor Python). Which is likely why this fails with a nonsense port.
# edit: okay, sometimes it seems to work. I would still avoid all this layer shader internals mess.
root: GvNode = CheckType(master.GetRoot())
objNode: GvNode = CheckType(master.CreateNode(root, c4d.ID_OPERATOR_OBJECT, x=50, y=50))
objNode[c4d.GV_OBJECT_OBJECT_ID] = shader
brokenAnglePort: GvPort | None = objNode.AddPort(c4d.GV_PORT_INPUT, did)
if not brokenAnglePort:
raise RuntimeError("Could not add port to node.")
print(f"{brokenAnglePort = }")
@staticmethod
def PythonNodeLayerShaderAccess(
shader: c4d.BaseShader, null: c4d.BaseObject,master: GvNodeMaster) -> None:
"""Creates a Python XPresso node that accesses the transform angle parameter of the first
layer in the given layer shader.
Args:
shader: The layer shader to manipulate.
null: The null object that hosts the XPresso tag and the 'Angle' user data field.
master: The node master of the XPresso tag.
"""
# As you said yourself, an easy way out is to use a Python node. So, let's start by creating
# a Python node.
root: GvNode = CheckType(master.GetRoot())
pyNode: GvNode = CheckType(master.CreateNode(root, c4d.ID_OPERATOR_PYTHON, x=200, y=200))
# Create a parameter description for a link box named "Shader Link" and add it as a user
# data field to the Python node. There are other ways to get "in" a BaseList2D into a Python
# node, but it does not have a BaseLink port type, so we always have get a bit creative.
# Another way could be to pass the UUID of the node, but this then always requires us
# searching the node which costs performance.
bc: c4d.BaseContainer = c4d.GetCustomDataTypeDefault(c4d.DTYPE_BASELISTLINK)
bc[c4d.DESC_NAME] = "Shader Link"
pyNode.AddUserData(bc)
# Now set the user data to our shader and the code to the code we define below.
pyNode[c4d.ID_USERDATA, 1] = shader
pyNode[c4d.GV_PYTHON_CODE] = Solution.CODE
# Now remove all the default ports from the node.
ports: list[GvPort] = CheckType((pyNode.GetInPorts() or []) + (pyNode.GetOutPorts() or []))
for p in ports:
pyNode.RemovePort(p)
# Now we are going to add in and output ports to the Python node. This works a bit differently
# than for other nodes, see https://developers.maxon.net/forum/topic/16302/ for a more in
# depth discussion of the subject.
angleInPort: GvPort = CheckType(pyNode.AddPort(
c4d.GV_PORT_INPUT,
c4d.DescID(
c4d.DescLevel(c4d.IN_REAL, c4d.DTYPE_SUBCONTAINER, pyNode.GetType()),
c4d.DescLevel(1000, c4d.DTYPE_REAL, 0)
)))
angleInPort.SetName("angle_in")
angleOutPort: GvPort = CheckType(pyNode.AddPort(
c4d.GV_PORT_OUTPUT,
c4d.DescID(
c4d.DescLevel(c4d.OUT_REAL, c4d.DTYPE_SUBCONTAINER, pyNode.GetType()),
c4d.DescLevel(1000, c4d.DTYPE_REAL, 0)
)))
angleOutPort.SetName("angle_out")
# Now we create nodes for the null object and a result node and connect them to the Python.
objNode: GvNode = CheckType(master.CreateNode(root, c4d.ID_OPERATOR_OBJECT, x=50, y=100))
resNode: GvNode = CheckType(master.CreateNode(root, c4d.ID_OPERATOR_RESULT, x=350, y=200))
# The output port for our "Angle" user data field on the null object.
objOutPort: GvPort | None = objNode.AddPort(
c4d.GV_PORT_OUTPUT,
c4d.DescID(
c4d.DescLevel(c4d.ID_USERDATA, c4d.DTYPE_SUBCONTAINER, objNode.GetType()),
c4d.DescLevel(1, c4d.DTYPE_REAL, 0)
))
if not objOutPort:
raise RuntimeError("Could add user data port to object node.")
# A result node can only have one input port, so we can also just do this without any danger
# that future versions of Cinema 4D might break this code. We could also be more verbose.
resInPort: GvPort | None = resNode.GetInPorts()[0] if resNode.GetInPorts() else None
if not resInPort:
raise RuntimeError("Could not get input port from result node.")
# And we wire everything up and are done, yay!
objOutPort.Connect(angleInPort)
angleOutPort.Connect(resInPort)
# The Python code used by the second, Python node based example, to set the code of the Python
# node.
CODE: str = '''import c4d
op: c4d.modules.graphview.GvNode # The Python Xpresso node containing this code.
def main() -> None:
"""Sets the value of the transform angle parameter of the first layer in the linked layer
shader and outputs the value to the output port.
"""
global angle_out
try:
shader: c4d.BaseShader = op[c4d.ID_USERDATA, 1] # The shader linked in the user data field.
if not isinstance(shader, c4d.LayerShader):
angle_out = 0.0
print("The linked shader is not a layer shader.")
return
# It is of course up to you to define the details of this. My code does not make much sense.
layer: c4d.LayerShaderLayer = shader.GetFirstLayer()
if layer.GetType() != c4d.TypeTransform:
angle_out = 0.0
print("The first layer is not a transform layer.")
return
if not layer.SetParameter(c4d.LAYER_S_PARAM_TRANS_ANGLE, angle_in):
angle_out = 0.0
print("Could not set angle parameter.")
return
angle_out = angle_in
except:
angle_out = 0.0
'''
if __name__ == '__main__':
Solution.main()