Skip to content

Conversation

@mnmly
Copy link

@mnmly mnmly commented Oct 28, 2025

Not sure if anyone needs this, but putting this here in case someone may find it useful...
This change allows injecting custom code to shader before being cached.

  1. ShaderCodeInjector Protocol: A sendable protocol that allows custom types to modify shader source code
  2. Registration API: registerInjector/unregisterInjector
  3. Injection Points: Custom code is injected after the standard shader source is resolved but before caching
  4. Material-Specific & Global Support: Injectors can target specific material types by label or apply
    globally using the "*" wildcard

[Example]:
I had OSC parameter group I can add/remove the parameters, then I wanted to insert the metal struct dynamically so I dont have to maintain it in the metal source code while prototyping...

struct OSCShaderInjector: ShaderCodeInjector {
    
    func injectCode(source: inout String, configuration: Satin.ShaderLibraryConfiguration) {
        // get struct string
        let uniformOSCStruct = OSCViewModel.shared.parameterGroup.structString
        let frameUniformStruct = FrameUniformViewModel.shared.parameterGroup.structString
        let structString = [uniformOSCStruct, frameUniformStruct].joined(separator: "\n")
        
        // append the content
        if let includeRange = source.range(of: "#include"),
           let lastInclude = source.range(of: "\n\n", range: includeRange.lowerBound..<source.endIndex) {
            source.insert(contentsOf: structString, at: lastInclude.upperBound)
        }
    }
}
class BasicPointCloudMaterial: SourceMaterial, OSCMaterial {

    var oscViewModel: OSCViewModel? = OSCViewModel.shared
    var oscUniformBuffer: Satin.UniformBuffer?
    var oscParameterGroup: Satin.ParameterGroup?
    
    public var color: simd_float4 {
        get {
            get("Color", as: Float4Parameter.self)!.value
        }
        set {
            set("Color", newValue)
        }
    }

    public var pointSize: Float = 2.0 {
        didSet {
            updateSize()
        }
    }
    
    public var contentScale: Float = 1.0 {
        didSet {
            updateSize()
        }
    }
    
    required init() {
        let pipelinesURL = Bundle.main.resourceURL!.appendingPathComponent("Assets/PointCloud/Pipelines")
        super.init(pipelinesURL: pipelinesURL, live: true)
        blending = .alpha
        color = .one
        pointSize = 2.0
        
        // Register injection, either put struct with `ShaderCodeInjector` protocol, 
        // or simply a closure with `(inout String, ShaderLibraryConfiguration) -> Void`
        ShaderLibrarySourceCache.registerInjector(OSCShaderInjector(), for: label)
    }
    
    required init(from decoder: any Decoder) throws {
        fatalError("init(from:) has not been implemented")
    }
    
    private func updateSize() {
        let size = pointSize * contentScale
        set("Size", size)
        set("Size Half", size * 0.5)
    }
    
    deinit {
        ShaderLibrarySourceCache.unregisterInjector(for: label)
    }
}

@vade
Copy link

vade commented Dec 9, 2025

Hey this is super interesting! Apologies for the slow reply, met me review and get back to you!

@vade
Copy link

vade commented Dec 9, 2025

So im trying to reason about if this makes sense in Satin main repo -

is the goal here to:

  • Expose an entry point for OSC control onto the parameter group?

If so, the shader has an existing parameter group, and I think introspecting the parameter group maybe a better way to do that (separation of concerns, reusability outside of the shader)

  • Create a reusable parameter group for specific controls that can be injectd into any arbitrary shader or material?

if the latter, id be curious why not either edit the shader, make a material instance yourself?

If you dont mind, can you walk through the use cases of this functionality, as i could be missing something useful, but im fearful it introduces some specific use case functionality that might be confusing for folks working with Satin and using different entry points to expose UI controls to Satin

@mnmly
Copy link
Author

mnmly commented Dec 10, 2025

hi, thanks for the reply!
And sorry it's been a while that I realised this OSC binding setup was not optimal and ended up not using this way anymore.

But other instance I found it a bit useful is the situation like below. In case you think this is a bit too much of the edge case, or if I'm missing other ways to handle these cases in Satin, please feel free dismiss / close this branch.


I setup PDAL library which I exposed its cpp api to swift so I can parse point cloud file (.ply/.xyz/.las/.geotiff...) and render them via Satin. It returns the interleaved buffer and variable length list of dimensions that contains the property features of x, y,z, Red, Green, Blue and colours sometimes comes in as UInt8 or UInt16. Depending on dataset, it sometimes adds other info too (classification etc). Therefore when I need to handle the buffer on metal, it gets a bit cumbersome to manually write the struct (also consider padding....) here I'm using this custom injection to add the variable structure definition, so I can technically use the same shader source (different shader instances) for rendering .ply or .laz or other point cloud files...

lazy var calculateDepthProcessor: CalculateDepthProcessor = {
    let device = context!.device
    let dimensions = pointCloud.dimensions
    let label = "\(self.label.filter { $0.isLetter })_CalculateDepth"
    
    ComputeShaderLibrarySourceCache.registerInjector({( source: inout String, config: ComputeShaderLibraryConfiguration) -> Void in
        source = source.replacingOccurrences(of: "// InterleavedBufferInjection", with: dimensions.constructMTLStruct())
        source = self.injectCode(source: source)
    }, for: label)
    
    let processor = CalculateDepthProcessor(label: label, device: device, pipelinesURL: RasterisedPointCloud.pipelinesURL, live: true)
    processor.delegate = self
    DispatchQueue.main.async {
        processor.shader?.recompile()
    }
    return processor
}()

And this is corresponding metal file that gets processed by the injection.

#include <metal_stdlib>

using namespace metal;

#include "../../Satin/VertexUniforms.metal"

// InterleavedBufferInjection <--- **this gets replaced by the custom injection**

struct CalculateDepthUniforms {
    uint pointCount;
    uint pointsOffset;
    float pointSize; // slider,0.0,10.0,1.0
};

kernel void calculateDepthUpdate(
    device const InterleavedPoint *interleavedBuffer [[buffer(ComputeBufferCustom1)]],
    device atomic<uint> *depthBuffer                 [[buffer(ComputeBufferCustom2)]],
    device atomic<uint> *attributeBuffer             [[buffer(ComputeBufferCustom3)]],
    const device VertexUniforms &vertexUniforms [[ buffer( ComputeBufferCustom10 ) ]],
    const device CalculateDepthUniforms &uniforms [[ buffer( ComputeBufferUniforms ) ]],
    uint3 gid [[thread_position_in_grid]],
    uint simd_lane_id [[thread_index_in_simdgroup]],
    uint simd_group_id [[simdgroup_index_in_threadgroup]]
)
{
    uint index = gid.x;
    if (index >= uniforms.pointCount) {
        return;
    }
    
    uint pointDataIndex = index + uniforms.pointsOffset;
    InterleavedPoint point = interleavedBuffer[pointDataIndex];
    ...
        // Prepare color once
    #ifdef COLOR_USHORT
        uint r = uint(clamp(float(point.red)  / float(USHRT_MAX) * 255.0, 0.0, 255.0));
        uint g = uint(clamp(float(point.green) / float(USHRT_MAX) * 255.0, 0.0, 255.0));
        uint b = uint(clamp(float(point.blue) / float(USHRT_MAX) * 255.0, 0.0, 255.0));
    #elif defined(COLOR_UCHAR)
        uint r = uint(point.red);
        uint g = uint(point.green);
        uint b = uint(point.blue);
    #elif defined(NO_COLOR)
        uint r = 255;
        uint g = 255;
        uint b = 255;
    #else
        uint r = uint(clamp(point.red * 255.0, 0.0, 255.0));
        uint g = uint(clamp(point.green * 255.0, 0.0, 255.0));
        uint b = uint(clamp(point.blue * 255.0, 0.0, 255.0));
    #endif
    uint a = 255;
    uint packedColor = r | (g << 8) | (b << 16) | (a << 24);
    uint uintDepth = as_type<uint>(clipW);
image
// for certain case of ply
struct InterleavedPoint {
    float x; // offset 0, size 4
    float y; // offset 4, size 4
    float z; // offset 8, size 4
    float red; // offset 12, size 4
    float green; // offset 16, size 4
    float blue; // offset 20, size 4
    float uvmap_x; // offset 24, size 4
    float uvmap_y; // offset 28, size 4
}; // Total Stride: 32

/// -----------------------------------------
// for the case of .laz file, no colours, 
#define NO_COLOR 1
struct InterleavedPoint {
    float x; // offset 0, size 4
    float y; // offset 4, size 4
    float z; // offset 8, size 4
    ushort intensity; // offset 12, size 2
    uchar returnNumber; // offset 14, size 1
    uchar numberOfReturns; // offset 15, size 1
    uchar scanDirectionFlag; // offset 16, size 1
    uchar edgeOfFlightLine; // offset 17, size 1
    uchar classification; // offset 18, size 1
    uchar synthetic; // offset 19, size 1
    uchar keyPoint; // offset 20, size 1
    uchar withheld; // offset 21, size 1
    uchar overlap; // offset 22, size 1
    uchar __padding23[1];
    float scanAngleRank; // offset 24, size 4
    uchar userData; // offset 28, size 1
    uchar __padding29[1];
    ushort pointSourceId; // offset 30, size 2
    float gpsTime; // offset 32, size 4
    uchar scanChannel; // offset 36, size 1
    uchar __paddingEnd[3]; // offset 37
}; // Total Stride: 40

Metal struct construction:

extension Array where Element == PDALDimensionInfo {
    func constructMTLStruct() -> String {
        
        var structString = ""
        
        // MARK: Construct COLOUR TYPE DECLARATION
        if let redDim = first(where: { String($0.name) == "Red" }) {
            if redDim.mtlTypeName == "ushort" {
                structString += "#define COLOR_USHORT 1\n"
            } else if redDim.mtlTypeName == "uchar" {
                structString += "#define COLOR_UCHAR 1\n"
            }
        } else {
            structString += "#define NO_COLOR 1\n"
        }
        if first(where: { String($0.name).lowercased() == "z" }) == nil {
            structString += "#define NO_Z 1\n"
        }

        structString += "struct InterleavedPoint {\n"
        
        var currentOffset = 0
        var maxAlignment = 1 // Track the largest alignment

        forEach { dim in
            let typeSize = dim.outputSize
            
            // This is the correct C-style alignment rule (size of the type)
            let alignment = typeSize
            
            // Track the largest alignment for final stride calculation
            if alignment > maxAlignment {
                maxAlignment = alignment
            }
            
            // Add padding if needed for alignment
            let misalignment = currentOffset % alignment
            if misalignment != 0 {
                let paddingNeeded = alignment - misalignment
                // Use a unique name for each padding
                structString += "    uchar __padding\(currentOffset)[\(paddingNeeded)];\n"
                currentOffset += paddingNeeded
            }
            
            let typeName = dim.mtlTypeName // Assumes this is "float", "ushort", etc.
            let varName = String(dim.name).camelCase // Assumes you have this utility
            structString += "    \(typeName) \(varName); // offset \(currentOffset), size \(typeSize)\n"
            currentOffset += typeSize
        }

        // Pad to the *largest member alignment* (which is 4)
        let remainder = currentOffset % maxAlignment
        if remainder != 0 {
            let paddingNeeded = maxAlignment - remainder
            structString += "    uchar __paddingEnd[\(paddingNeeded)]; // offset \(currentOffset)\n"
            currentOffset += paddingNeeded
        }
        structString += "}; // Total Stride: \(currentOffset)\n"
        return structString
    }
}

@vade
Copy link

vade commented Dec 10, 2025

Thats super interesting - i see how that could be interesting and useful. Thanks for explaining it. Let me review now that i have a better sense of capabilities. Thanks for this!

@vade
Copy link

vade commented Dec 10, 2025

One quick question, could this not be handled without shader injection by upcasting to uint16 or 32 for some of the buffer contents, so theres a consistent buffer type?

@mnmly
Copy link
Author

mnmly commented Dec 10, 2025

that's also an option, too! I wanted to avoid upscaling on my use case of handling large number of pointcloud like millions of points, where its memory consumption is also increasing if I normalise colours, or other properties by upcasting them, so I ended up not upcasting...

just as the example above, when I import .ply point cloud from blender export for instance, I need to prepare the following structs in metal.

// for certain case of ply
struct InterleavedPoint {
    float x; // offset 0, size 4
    float y; // offset 4, size 4
    float z; // offset 8, size 4
    float red; // offset 12, size 4
    float green; // offset 16, size 4
    float blue; // offset 20, size 4
    float uvmap_x; // offset 24, size 4
    float uvmap_y; // offset 28, size 4
}; // Total Stride: 32

and if I load the .laz file, the buffer comes with a lot of properties (dimensions).

struct InterleavedPoint {
    float x; // offset 0, size 4
    float y; // offset 4, size 4
    float z; // offset 8, size 4
    ushort intensity; // offset 12, size 2
    uchar returnNumber; // offset 14, size 1
    uchar numberOfReturns; // offset 15, size 1
    uchar scanDirectionFlag; // offset 16, size 1
    uchar edgeOfFlightLine; // offset 17, size 1
    uchar classification; // offset 18, size 1
    uchar synthetic; // offset 19, size 1
    uchar keyPoint; // offset 20, size 1
    uchar withheld; // offset 21, size 1
    uchar overlap; // offset 22, size 1
    uchar __padding23[1];
    float scanAngleRank; // offset 24, size 4
    uchar userData; // offset 28, size 1
    uchar __padding29[1];
    ushort pointSourceId; // offset 30, size 2
    float gpsTime; // offset 32, size 4
    uchar scanChannel; // offset 36, size 1
    uchar __paddingEnd[3]; // offset 37
}; // Total Stride: 40

I also agree the framework should avoid modifying the hand-written shader code, but I couldn't find a good solution to inject those dynamic custom snippet otherwise so far and I may be missing something. it could be my niche use case that it's completely fine not to merge this PR for now, too. maybe there are better ways to handle this...

@vade
Copy link

vade commented Dec 10, 2025

definitely, i want to handle both point cloud as well as splats in Satin, and construct a nice reusable set of loaders, materials etc that operate natively on those types. So i think the use case is helpful guidance on solutions.

I hear you about memory usage as well.

Thanks for the dialogue on this, maybe we can find a nice general solution here that solves a few use cases together?

I had done some minor work on https://github.com/scier/MetalSplatter and have been background processing converting it to Satin native solutions. Theres a fork or two which implements spherical harmonics, which does bloat memory in some sense.

Id be curious if there are additional use cases for these dynamic inserts, im sure there are.

@mnmly
Copy link
Author

mnmly commented Dec 10, 2025

I was also working on 3dgs on satin a bit referring to MetalSplatter too! but as you said Spherical coefficients props such f_rest_0 ~ f_rest_n may require a bit of flexibility on how much properties do we need/want to pass to shader. But I think I also need to spend some time figuring out other cases.

I managed to bring it onto Satin, but I'm not well versed into graphics enough to render it efficiently... but served a purpose.
image

Other metal based 3dgs renderer like gsm-renderer seemed to be composed of series of compute shaders, and I was wondering how I could incorporate those pipelines flexibly on top of Satin.

@vade
Copy link

vade commented Dec 10, 2025

Awesome!

I found this port of MetalSplatter which seems to leverage spherical harmonics: (in git commits you can find it)

https://github.com/lanxinger/MetalSplatter

Satin def supports Compute, it should be doable:)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants