diff --git a/python/Scripts/generateshader.py b/python/Scripts/generateshader.py index 46ff359043..1e26752c7e 100644 --- a/python/Scripts/generateshader.py +++ b/python/Scripts/generateshader.py @@ -53,6 +53,7 @@ def main(): parser.add_argument('--validatorArgs', dest='validatorArgs', nargs='?', const=' ', type=str, help='Optional arguments for code validator.') parser.add_argument('--vulkanGlsl', dest='vulkanCompliantGlsl', default=False, type=bool, help='Set to True to generate Vulkan-compliant GLSL when using the genglsl target.') parser.add_argument('--shaderInterfaceType', dest='shaderInterfaceType', default=0, type=int, help='Set the type of shader interface to be generated') + parser.add_argument('--dumpHash', dest='dumpHash', action='store_true', default=False, help='Print the structural graph hash for each generated shader.') parser.add_argument(dest='inputFilename', help='Path to input document or folder containing input documents.') opts = parser.parse_args() @@ -156,6 +157,10 @@ def main(): elemName = mx.createValidName(elemName) shader = shadergen.generate(elemName, elem, context) if shader: + if opts.dumpHash: + structHash = mx_gen_shader.computeStructuralHash(shader) + print(f'--- Structural hash: 0x{structHash:016x}') + # Use extension of .vert and .frag as it's type is # recognized by glslangValidator if gentarget in ['glsl', 'essl', 'vulkan', 'msl', 'wgsl']: diff --git a/source/MaterialXGenShader/ShaderGraphHash.cpp b/source/MaterialXGenShader/ShaderGraphHash.cpp new file mode 100644 index 0000000000..d89b42fefb --- /dev/null +++ b/source/MaterialXGenShader/ShaderGraphHash.cpp @@ -0,0 +1,148 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#include + +#include + +MATERIALX_NAMESPACE_BEGIN + +namespace +{ + +void hashCombine(size_t& seed, size_t value) +{ + seed ^= value + 0x9e3779b9 + (seed << 6) + (seed >> 2); +} + +void hashString(size_t& seed, const string& str) +{ + hashCombine(seed, std::hash()(str)); +} + +void hashUint32(size_t& seed, uint32_t value) +{ + hashCombine(seed, std::hash()(value)); +} + +void hashSize(size_t& seed, size_t value) +{ + hashCombine(seed, std::hash()(value)); +} + +void hashPortStructure(size_t& seed, const ShaderPort* port) +{ + hashString(seed, port->getType().getName()); + hashString(seed, port->getSemantic()); + hashString(seed, port->getColorSpace()); + hashString(seed, port->getUnit()); + hashString(seed, port->getGeomProp()); + + uint32_t structuralFlags = port->getFlags() & + (ShaderPortFlag::UNIFORM | ShaderPortFlag::BIND_INPUT); + hashUint32(seed, structuralFlags); +} + +size_t findOutputIndex(const ShaderOutput* output) +{ + const ShaderNode* node = output->getNode(); + for (size_t i = 0; i < node->numOutputs(); ++i) + { + if (node->getOutput(i) == output) + return i; + } + return SIZE_MAX; +} + +} // anonymous namespace + +size_t computeStructuralHash(const ShaderGraph& graph) +{ + size_t seed = 0; + + // 1. Hash graph-level input sockets (count + structural type info, no names or values) + hashSize(seed, graph.numInputSockets()); + for (const ShaderGraphInputSocket* socket : graph.getInputSockets()) + { + hashPortStructure(seed, socket); + } + + // 2. Hash graph-level output sockets (count + structural type info) + hashSize(seed, graph.numOutputSockets()); + for (const ShaderGraphOutputSocket* socket : graph.getOutputSockets()) + { + hashPortStructure(seed, socket); + } + + // 3. Build a stable index for each node using topological order + const auto& nodes = graph.getNodes(); + std::unordered_map nodeIndex; + nodeIndex.reserve(nodes.size() + 1); + for (size_t i = 0; i < nodes.size(); ++i) + { + nodeIndex[nodes[i]] = i; + } + // The graph itself can appear as a connection source (for graph input sockets) + constexpr size_t GRAPH_SELF_INDEX = SIZE_MAX; + constexpr size_t NO_CONNECTION_SENTINEL = SIZE_MAX - 1; + constexpr size_t UNKNOWN_NODE_SENTINEL = SIZE_MAX - 2; + nodeIndex[&graph] = GRAPH_SELF_INDEX; + + // 4. Hash each node in topological order + hashSize(seed, nodes.size()); + for (const ShaderNode* node : nodes) + { + hashSize(seed, node->getImplementation().getHash()); + hashUint32(seed, node->getClassification()); + + // Node inputs + hashSize(seed, node->numInputs()); + for (const ShaderInput* input : node->getInputs()) + { + hashPortStructure(seed, input); + + const ShaderOutput* conn = input->getConnection(); + if (conn) + { + auto it = nodeIndex.find(conn->getNode()); + size_t srcIdx = (it != nodeIndex.end()) ? it->second : UNKNOWN_NODE_SENTINEL; + hashSize(seed, srcIdx); + hashSize(seed, findOutputIndex(conn)); + } + else + { + hashSize(seed, NO_CONNECTION_SENTINEL); + } + } + + // Node outputs + hashSize(seed, node->numOutputs()); + for (const ShaderOutput* output : node->getOutputs()) + { + hashPortStructure(seed, output); + } + } + + // 5. Hash graph output socket connections + for (const ShaderGraphOutputSocket* socket : graph.getOutputSockets()) + { + const ShaderOutput* conn = socket->getConnection(); + if (conn) + { + auto it = nodeIndex.find(conn->getNode()); + size_t srcIdx = (it != nodeIndex.end()) ? it->second : UNKNOWN_NODE_SENTINEL; + hashSize(seed, srcIdx); + hashSize(seed, findOutputIndex(conn)); + } + else + { + hashSize(seed, NO_CONNECTION_SENTINEL); + } + } + + return seed; +} + +MATERIALX_NAMESPACE_END diff --git a/source/MaterialXGenShader/ShaderGraphHash.h b/source/MaterialXGenShader/ShaderGraphHash.h new file mode 100644 index 0000000000..06a5c21dda --- /dev/null +++ b/source/MaterialXGenShader/ShaderGraphHash.h @@ -0,0 +1,38 @@ +// +// Copyright Contributors to the MaterialX Project +// SPDX-License-Identifier: Apache-2.0 +// + +#ifndef MATERIALX_SHADERGRAPHHASH_H +#define MATERIALX_SHADERGRAPHHASH_H + +/// @file +/// Structural hash computation for shader graphs + +#include +#include + +MATERIALX_NAMESPACE_BEGIN + +/// Compute a pure structural hash of a shader graph that captures its +/// topology and node types, independent of instance names and values. +/// +/// Two graphs with identical structure (same node implementations, +/// same connection pattern, same port types) will produce the same +/// hash even if they differ in node names, variable names, or uniform +/// values. The hash is computed over the finalized graph and includes: +/// - Graph input/output socket counts and types +/// - Node implementation hashes and classifications +/// - Connection topology (source node topological index + output index) +/// - Structurally-significant port attributes: type, semantic, +/// colorspace, unit, geomprop, uniform/bind-input flags +/// +/// Explicitly excluded: node names, port names, port values/defaults. +/// +/// This is an external visitor function that only uses the public API +/// of ShaderGraph, ShaderNode, and ShaderPort. +MX_GENSHADER_API size_t computeStructuralHash(const ShaderGraph& graph); + +MATERIALX_NAMESPACE_END + +#endif diff --git a/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp b/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp index e99d83a452..937336738c 100644 --- a/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp +++ b/source/MaterialXTest/MaterialXGenGlsl/GenGlsl.cpp @@ -21,6 +21,12 @@ #include #include #include +#include +#include +#include + +#include +#include namespace mx = MaterialX; @@ -220,3 +226,93 @@ TEST_CASE("GenShader: Wgsl GLSL Shader Generation", "[genglsl]") { generateGlslCode(GlslType::GlslWgsl); } + +TEST_CASE("GenShader: GLSL Structural Hash", "[genglsl]") +{ + mx::DocumentPtr nodeLibrary = mx::createDocument(); + const mx::FileSearchPath searchPath = mx::getDefaultDataSearchPath(); + + loadLibraries({ "libraries" }, searchPath, nodeLibrary); + + mx::GenContext context(mx::GlslShaderGenerator::create()); + context.registerSourceCodeSearchPath(searchPath); + + mx::DefaultColorManagementSystemPtr colorManagementSystem = + mx::DefaultColorManagementSystem::create(context.getShaderGenerator().getTarget()); + REQUIRE(colorManagementSystem); + context.getShaderGenerator().setColorManagementSystem(colorManagementSystem); + colorManagementSystem->loadLibrary(nodeLibrary); + + mx::UnitSystemPtr unitSystem = mx::UnitSystem::create(context.getShaderGenerator().getTarget()); + REQUIRE(unitSystem); + context.getShaderGenerator().setUnitSystem(unitSystem); + unitSystem->loadLibrary(nodeLibrary); + unitSystem->setUnitConverterRegistry(mx::UnitConverterRegistry::create()); + mx::UnitTypeDefPtr distanceTypeDef = nodeLibrary->getUnitTypeDef("distance"); + unitSystem->getUnitConverterRegistry()->addUnitConverter(distanceTypeDef, mx::LinearUnitConverter::create(distanceTypeDef)); + mx::UnitTypeDefPtr angleTypeDef = nodeLibrary->getUnitTypeDef("angle"); + unitSystem->getUnitConverterRegistry()->addUnitConverter(angleTypeDef, mx::LinearUnitConverter::create(angleTypeDef)); + context.getOptions().targetDistanceUnit = "meter"; + + mx::FilePathVec testRootPaths; + testRootPaths.push_back(searchPath.find("resources/Materials/Examples/StandardSurface")); + + std::vector loadedDocuments; + mx::StringVec documentsPaths; + mx::StringVec errorLog; + + for (const auto& testRoot : testRootPaths) + { + mx::loadDocuments(testRoot, searchPath, {}, {}, loadedDocuments, documentsPaths, + nullptr, &errorLog); + } + + REQUIRE(loadedDocuments.size() > 0); + + std::ostringstream hashLog; + hashLog << std::hex << std::setfill('0'); + hashLog << "\n=== Structural Hash Results ===\n"; + + size_t numHashed = 0; + for (size_t docIdx = 0; docIdx < loadedDocuments.size(); ++docIdx) + { + mx::DocumentPtr doc = loadedDocuments[docIdx]; + doc->setDataLibrary(nodeLibrary); + + std::string message; + bool docValid = doc->validate(&message); + INFO(documentsPaths[docIdx] << ": " << message); + REQUIRE(docValid); + + context.getShaderGenerator().registerTypeDefs(doc); + + std::vector elements = mx::findRenderableElements(doc); + for (const mx::TypedElementPtr& element : elements) + { + mx::ShaderPtr shader; + REQUIRE_NOTHROW(shader = context.getShaderGenerator().generate(element->getName(), element, context)); + REQUIRE(shader != nullptr); + + size_t hash1 = mx::computeStructuralHash(shader->getGraph()); + REQUIRE(hash1 != 0); + + // Determinism check: generate the same shader again and verify the hash matches. + mx::ShaderPtr shader2; + REQUIRE_NOTHROW(shader2 = context.getShaderGenerator().generate(element->getName(), element, context)); + REQUIRE(shader2 != nullptr); + size_t hash2 = mx::computeStructuralHash(shader2->getGraph()); + REQUIRE(hash1 == hash2); + ++numHashed; + + hashLog << " " << documentsPaths[docIdx] << " | " + << element->getName() << " | 0x" + << std::setw(sizeof(size_t) * 2) << hash1 << "\n"; + } + } + + hashLog << "=== End Structural Hash Results ===\n"; + + // Output to Catch2 INFO so it appears with -s flag + INFO(hashLog.str()); + REQUIRE(numHashed > 0); +} diff --git a/source/PyMaterialX/PyMaterialXGenShader/PyUtil.cpp b/source/PyMaterialX/PyMaterialXGenShader/PyUtil.cpp index 6c73c2866f..d4190a0c85 100644 --- a/source/PyMaterialX/PyMaterialXGenShader/PyUtil.cpp +++ b/source/PyMaterialX/PyMaterialXGenShader/PyUtil.cpp @@ -6,11 +6,18 @@ #include #include +#include #include +#include namespace py = pybind11; namespace mx = MaterialX; +size_t computeStructuralHashFromShader(const mx::Shader& shader) +{ + return mx::computeStructuralHash(shader.getGraph()); +} + std::vector findRenderableMaterialNodes(mx::ConstDocumentPtr doc) { return mx::findRenderableMaterialNodes(doc); @@ -36,4 +43,5 @@ void bindPyUtil(py::module& mod) mod.def("getUdimScaleAndOffset", &mx::getUdimScaleAndOffset); mod.def("connectsToWorldSpaceNode", &mx::connectsToWorldSpaceNode); mod.def("hasElementAttributes", &mx::hasElementAttributes); + mod.def("computeStructuralHash", &computeStructuralHashFromShader); }