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 samplingTexture2D TextureInfo
: information aboutTexture
, 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 beinput.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 useinput.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 theSampleCustomEffects
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 thecohtml::View::SetSceneCustomEffectRenderer
API.UserEffect Effect
: Structure hosting the custom float and string parameters, that were passed through thecoh-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>;
.
UserEffect
structure has more storage than needed for the custom parameters passed. That storage is reserved for future use. Currently, the first 4 float parameters and the first 2 string parameters convey the data passed through coh-custom-effect-<TYPE>-param-<N>
. All the other parameters do not contain meaningful data.TextureInfo
, Viewport
and RenderTargetSize
fields are usually used for effects that cache textures and need that information for validation. For example, if you need to wrap the device texture in another texture abstraction in your engine, you’d need the width/height of the texture, which can be obtained from TextureInfo
.coh-custom-effect-float-param-<N>
properties so the element thinks it needs redrawing.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;
}
TargetGeometryPositionSize.zw
, a translation matrix from TargetGeometryPositionSize.xy
, and finally multiplying by TransformMatrix
. This way you can only update the shader constant buffers and not the vertex/index buffer.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 UI 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 UI 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
tofalse
- Set
- When creating a View with
System::CreateView
:- Set
ViewSettings::ExecuteLayoutOnDedicatedThread
tofalse
- Set
ViewSettings::ExecuteCommandProcessingWithLayout
totrue
- Set
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 theDrawCustomEffectCmd
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 theView
object for the effect so that we can employ custom logic there (as theRendererBackend
is global for all views). - A custom backend is created. It needs to handle the processing of the
BC_DrawCustomEffect
command in theExecuteRendering
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
- A custom Vertex Shader (see the
- 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 propertycoh-custom-effect-float-param-N
corresponds to theParams[N-1]
floating-point parameter.
- The update of the constant buffers is done using the parameters passed through the
- 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.