Custom Effects

Prysm since version 1.9.5 supports a few new custom properties, allowing for passing custom data for embedding user effects that are otherwise not possible with standard CSS methods.

These new properties are:

  • coh-custom-effect-name
  • coh-custom-effect-float-param-*
    • 1-4 are Animatable
    • 4-12 are not Animatable
  • coh-custom-effect-string-param-1
  • coh-custom-effect-string-param-2

Adding coh-custom-effect-name property will automatically trigger an alternative code flow and emit renoir::DrawCustomEffectCmd commands that should be handled by the backend. The client can set custom vertex/pixel shaders that achieve some custom effect.

struct VS_INPUT
{
    float4 Position : POSITION;
    float4 Color : TEXCOORD0;
    float4 Additional : TEXCOORD1; // .xy = texture coordinates
};

The renoir::DrawCustomEffectCmd has the following information:

  • Texture2DObject Texture: the backend texture ID to be used as a source for sampling
  • Texture2D TextureInfo: information about Texture, e.g. dimensions.
  • float4 UVScaleBias: The custom effect is always drawn as a quad with UVs [0,0-1,1] at the corners. This field describes the required scale (.xy) and offset (.zw) in the texture so the sampling is correct, in case the input texture is part of an atlas, for example. Basically, in that case, the sampled location should be input.Additional.xy * UVScaleBias.xy + UVScaleBias.zw if using custom geometry. If not, the SDK would have preset the proper coordinates for the vertices in the bound vertex buffer and you can directly use input.Additional.xy. The scale and bias may then be used for computing effect parameters derived from the size of the sampled area, as it is done in the SampleCustomEffects sample.
  • float4 TargetGeometryPositionSize: Position (.xy) and size (.zw) in the render target, in pixels.
  • float4 Viewport: Currently set the viewport for the render target.
  • float2 RenderTargetSize: Size of the render target.
  • unaligned_float4x4 TransformMatrix: Transformation matrix of the quad.
  • void* UserData: Arbitrary user data set with the cohtml::View::SetSceneCustomEffectRenderer API.
  • UserEffect Effect: Structure hosting the custom float and string parameters, that were passed through the coh-custom-effect-<TYPE>-param-<N> CSS properties.

Here’s an example of what the DrawCustomEffectCmd structure might contain:

#elementWithCustomEffect {
    coh-custom-effect-name: MyEffect;
    animation: forceRedraw 1s infinite;
}
@keyframes forceRedraw {
    from {
        coh-custom-effect-float-param-4: 0;
    }
    to {
        coh-custom-effect-float-param-4: 1;
    }
}

This consumes a slot for a float parameter but solves the issue with the repaint, although it does force the element to be redrawn every frame. A similar solution is to simply set the corresponding JavaScript, i.e. elementWithCustomEffect.style.cohCustomEffectFloatParam4 = <some value different than before>;.

Detailed explanation

Custom effect rendering is designed to support various systems with different requirements. The first API that you’ll encounter is cohtml::View::SetSceneCustomEffectRenderer. Using that API, you can set the implementation of the renoir::ICustomEffectRenderer interface, along with some arbitrary user data that will be passed back to the rendering backend unmodified. The interface supports one callback: OnRenderCustomEffect, receiving the whole DrawCustomEffectCmd structure as a parameter. As a second parameter, it also receives the whole render state (in the form of backend commands, wrapped in the renoir::ICustomEffectRenderer::RenderState structure) that the SDK expects for rendering the output. This parameter is used when executing effects that need to do some temporary state changes so you can return it to the original. By default, that callback is invoked on the Render Thread, which can be configured. This is discussed in the section on using a custom Material rendering API.

If you’ll be able to implement your custom effect entirely on the Render Thread, this callback doesn’t bring much value, since you’ll get the same exact data in the DrawCustomEffectCmd command that’s passed in the BackendCommandsBuffer stream to the RendererBackend::ExecuteCommands method.

You’d still make use of the user data set by the cohtml::View::SetSceneCustomEffectRenderer method. Since the renderer backend is one for all Views, you might want to have some means for differentiating which View exactly tries to draw the custom effect. The user data is passed to the DrawCustomEffectCmd::UserData field as-is. Usually, you’d attach a custom effect renderer to each of your Views and pass that as the user data.

In the most common scenario, you’ll simply need to apply a pixel shader over the element. You’ll simply want to bind the DrawCustomEffectCmd::Texture image on input and sample the input.Additional.xy location (if using the preset vertex buffer), or input.Additional.xy * UVScaleBias.xy + UVScaleBias.zw if using a custom quad with UVs [0..1], then apply the shading.

If you need to apply a vertex shader as well, the TransformMatrix will be needed. The SDK assumes that a rectangle defined by TargetGeometryPositionSize.xy as the top-left corner and TargetGeometryPositionSize.zw as width/height will be transformed using TransformMatrix and that will be the final position in the render target. The geometry is pre-set and bound by the SDK so if you don’t change it you only need to apply TransformMatrix to the vertices in the shader. A final transformation into NDC space is required after that. Here’s a sample for a complete shader:

struct VS_INPUT
{
    float4 Position : POSITION;
    float4 Color : TEXCOORD0;
    float4 Additional : TEXCOORD1;
};

struct PS_INPUT
{
    float4 Position : SV_POSITION;
    float2 UV : TEXCOORD0;
};

cbuffer CustomVSConstants : register(b0)
{
    float4x4 TransformMatrix;
}

PS_INPUT CustomVS(VS_INPUT input)
{
    PS_INPUT output;

    output.Position = mul(input.Position, TransformMatrix);
    // Translate to -1..1 with perspective correction
    float w = output.Position.w;
    output.Position.x = output.Position.x * 2 - w;
    output.Position.y = (w - output.Position.y) * 2 - w;

    output.UV = input.Additional.xy;

    return output;
}

Threading

A third more complex case is where you use some Material rendering API of the engine of your choice, and that requires callbacks to be invoked on the Main Thread. This is where you make use of the renoir::ICustomEffectRenderer interface and more specifically, the renoir::ICustomEffectRenderer::OnRenderCustomEffect callback. It can be configured to be called on Main Thread, making available all the data you need for rendering where you need it. To do that, you need to initialize the CoHTML SDK with the following parameters:

  • When initializing the Library with Library::Initialize:
    • Set LibraryParams::UseDedicatedLayoutThread to false
  • When creating a View with System::CreateView:
    • Set ViewSettings::ExecuteLayoutOnDedicatedThread to false
    • Set ViewSettings::ExecuteCommandProcessingWithLayout to true

Effects with multiple passes

Prysm sets up the graphics state before issuing the DrawCustomEffectCmd command so that the graphics API is ready for drawing a rectangular geometry in the correct render target. The client only has to bind a custom Pixel shader, bind the passed texture on input and issue a Draw call.

If you’re doing a more complex effect, such as a blur of some kind, you’ll want to change the render target, and some other graphics states, such as the scissor, viewport, etc. After, those changes, you’ll have to set the whole state back to what Prysm expects so the result is in the correct texture. That’s what the ICustomEffectRenderer::RenderState parameter in the renoir::ICustomEffectRenderer::OnRenderCustomEffect callback is used for. It contains all the state commands that need to be applied to restore the graphics state back to what it was before the DrawCustomEffectCmd. The parameters are in the form of backend commands as those will usually be processed by the backend, which already has the means for doing so. You need to apply that state before the last pass of your effect.

Here’s a diagram that illustrates what and when is set up:

Putting it all together

Let’s take the following example:

  • There are multiple Prysm Views in the system
  • Only one of them applies custom effects
  • The custom effect will apply a simple component-wise color multiplication based on the custom parameters in the CSS

We’ll use this simple HTML page that displays an image and animates the custom effect parameters

<html>
<head>
<style>
body {
    background-color: white;
}

#customEffect {
    position: absolute;
    left: 100px;
    top: 100px;
    width: 100px;
    height: 100px;
    coh-custom-effect-name: TheOnlyEffectThatShouldBeRendered;
    background-image: url('icon.png');
    animation: pulse 2s infinite;
}

@keyframes pulse {
    from {
        coh-custom-effect-float-param-1: 0;
        coh-custom-effect-float-param-2: 1;
        coh-custom-effect-float-param-3: 0.4;
    }
    to {
        coh-custom-effect-float-param-1: 1;
        coh-custom-effect-float-param-2: 0;
        coh-custom-effect-float-param-3: 0.9;
    }
}

</style>
</head>
<body>

<div id="customEffect"></div>

</body>

</html>

Here’s the final result, which is completely driven by the CSS code:

In the code, we’ll have to set up an object that is associated with the View that renders the custom effects. That’s because the rendering backend is shared for all Views and we’ll need to identify the View in some manner.

We’ll create a simple class that allows the rendering of the custom effect if it’s called “TheOnlyEffectThatShouldBeRendered”.

class CustomEffectHandler : public renoir::ICustomEffectRenderer
{
public:
    bool ShouldRenderEffect(const DrawCustomEffectCmd* command)
    {
        if (strcmp(command->Effect.Name, "TheOnlyEffectThatShouldBeRendered") == 0)
        {
            return true;
        }
        return false;
    }

    virtual void OnRenderCustomEffect(const DrawCustomEffectCmd& command, const RenderState& targetState) override
    {
        // In the simple case where the SDK is configured to execute
        // command batching on the render thread, nothing is needed here.
    }
};
// Some class that stores a persistent variable for the CustomEffectHandler instance
// and the CoHTML View
std::unique_ptr<CustomEffectHandler> m_CustomEffectHandler;
... initialize m_CustomEffectHandler ...
cohtml::View* m_View;
// After creating the View...
// We won't use the ICustomEffectRenderer now, so we can pass null there,
// only user data.
// In this case CustomEffectHandler doesn't need to inherit from the
// ICustomEffectRenderer interface but we do it for completeness
m_View->SetSceneCustomEffectRenderer(nullptr, m_CustomEffectHandler.get());

We’ll take the D3D11 backend as an example for handling the DrawCustomEffectCmd

This is the shader that we’re going to use:

// This shader is basically a copy of CohStandardPS.hlsl
// The input texture we need to sample from is bound on "txBuffer"
// The CSS float parameters are bound on "PrimProps0"
// Check the CohCommonPS.ihlsl file for the definition of those variables.

#include "CohPlatform.ihlsl"
#include "CohStandardCommon.ihlsl"
#include "CohCommonPS.ihlsl"
#include "CohShadeGeometry.ihlsl"

float4 CustomEffect(PS_INPUT input) : SV_Target
{
    float alpha = 1.0;
    float4 outColor = input.Color;

    outColor = SAMPLE2D(txBuffer, input.Additional.xy);
    outColor.rgb = saturate(outColor.rgb * PrimProps0.rgb);

    alpha = input.Color.a * saturate(input.Additional.z);

    return outColor * alpha;
}

We’ll assume that shader was set up in the backend and can be accessed through the m_CustomEffectShader variable.

void Dx11Backend::DrawCustomEffect(const DrawCustomEffectCmd* command)
{
    CustomEffectHandler* effectHandler = (CustomEffectHandler*)command->UserData;
    // Note that the backend is global for all Views. In case you need to employ
    // some custom per-view logic, it's advised to have a reference to the View
    // in the user data structure (CustomEffectHandler in this case)
    if (!effectHandler || !effectHandler->ShouldRenderEffect(command))
    {
        return;
    }

    // Set up topology
    if (m_CurrentTopology != command->Topology)
    {
        switch (command->Topology)
        {
        case PTO_TriangleList:
            m_ImmediateContext->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
            break;
        default:
            assert(false);
            std::cerr << "Unknown primitive topology!" << std::endl;
            break;
        }

        m_CurrentTopology = command->Topology;
    }

    // Set up the custom pixel shader you have
    m_ImmediateContext->PSSetShader(m_CustomEffectShader.Get(), nullptr, 0);

    // Find the texture that needs to be bound on input (command->Texture)
    {
        ID3D11ShaderResourceView* srv = nullptr;
        auto texId = command->Texture;

        const auto& tex = m_Textures.find(texId);
        if (tex != m_Textures.cend()) // likely
        {
            srv = tex->second.SRV.Get();
        }
        else
        {
            // Try to find the texture in the user render targets
            auto rt = std::find_if(m_RTs.begin(), m_RTs.end(), [texId](const RTImpl& p)
                {
                    return p.first == texId;
                });
            if (rt != m_RTs.cend())
            {
                assert(!m_BackBufferSRV.Get());
                m_BackBufferSRV.Set(CreateSRVFromRTV(m_Device, rt->second.RTDescription, rt->second.RTV));
                srv = m_BackBufferSRV.Get();
            }
            else
            {
                assert(false);
                std::cerr << "Unable to find texture!" << std::endl;
                return;
            }
        }
        m_ImmediateContext->PSSetShaderResources(0, 1, &srv);
    }

    // Update the constant buffer with the variables
    // Note that getting the targetBufferId this way is not recommended, this works only
    // in the case where the backend has a constant buffer ring size of 1!
    // It's usually better to create and bind your own constant buffer.
    // Use the SampleCustomEffects for reference.
    renoir::ObjectId targetBufferId = CB_StandardPrimitiveAdditionalPixel;
    auto cbIt = std::find_if(m_ConstantBuffers.begin(), m_ConstantBuffers.end(),
        [targetBufferId](const ConstantBufferPair& cbp) {
            return cbp.first.Id == targetBufferId;
        });
    assert(cbIt != m_ConstantBuffers.end());

    struct PrimPropsUpdate
    {
        renoir::float4 PrimProps0;
        renoir::float4 PrimProps1;
    } update;
    update.PrimProps0 = float4(cmd->Effect.Params[0], cmd->Effect.Params[1], cmd->Effect.Params[2], cmd->Effect.Params[3]);
    m_ImmediateContext->UpdateSubresource(cbIt->second.Get(), 0, nullptr, &update, 0, 0);

    // Draw!
    m_ImmediateContext->DrawIndexed(command->IndexCount, command->StartIndex, command->BaseVertex);
}

Sample

You can find a complete sample demonstrating the usage of custom effects in the Samples directory of the SDK named SampleCustomEffects. Currently, the sample only works under Windows and uses D3D11. The sample shows a configurable twirl effect.

Here’s a quick rundown of what happens there:

  • First, we should note that the sample doesn’t use the cohtml::View::SetSceneCustomEffectRenderer API for setting a custom renderer and/or user data. The sample would use the DrawCustomEffectCmd data directly on the render thread so we don’t need a custom renderer and we only have 1 view and 1 effect so we don’t need custom data either. Usually, custom data would contain the View object for the effect so that we can employ custom logic there (as the RendererBackend is global for all views).
  • A custom backend is created. It needs to handle the processing of the BC_DrawCustomEffect command in the ExecuteRendering virtual method. The D3D11 backend conveniently exposes another virtual function that is invoked automatically when processing the command - virtual void DrawCustomEffect(const DrawCustomEffectCmd* command).
  • When initializing the backend, some resources needed for the twirl effect are created
    • A custom Vertex Shader (see the VS_INPUT structure at the beginning of the chapter for the vertex layout)
    • A custom Pixel Shader
    • Constant buffer for the VS
      • We use the already bound vertex buffer so we don’t need to do any UV modifications and pass the input coordinates straight through to the Pixel shader
    • Constant buffer for the PS
    • A texture sampler, although we could use the default here
  • The implementation of the command needs to bind all the needed resources, update the constant buffers and execute the draw command
    • The update of the constant buffers is done using the parameters passed through the DrawCustomEffectCmd::UserEffect::Params array. The CSS property coh-custom-effect-float-param-N corresponds to the Params[N-1] floating-point parameter.
  • On the HTML side, the code is pretty straightforward. There are several slider inputs which set the custom float parameters through JavaScript (e.g. customFXel.style.cohCustomEffectFloatParam2 = val / 180 * Math.PI; // Convert deg to rad). That’s all that’s needed for setting the parameters, they are then automatically passed the whole way through the backend.