Please note that this example is intended to demonstrate how Apex Post-processing works. From Mako 8.2.0, halftone screening is built into the Apex API.
Introduction
Conventional screening is a technique used in print and digital imaging to simulate continuous-tone images by breaking them into a pattern of small, regularly spaced dots. Each dot’s size and distribution vary based on the tonal values in the image, with lighter areas having smaller dots and darker areas having larger dots. This halftoning process mimics the smooth gradients of continuous tones, making it essential for reproducing color images in print using processes like CMYK.
In this example, a halftone screen is applied to the result of rendering to CMYK with Apex. It produces a result similar to IJawsRenderer::renderScreened() with a CColorSpotHalftone.
There are four stages to this process:
Creating an Apex CCustomColorPostProcessSpec that loads a compiled shader and specifies parameters such as line frequency
Creating an Apex CRenderSpec to specify the rendering parameters
Calling the renderer, which both renders then executes the post-process
Encoding the result as TIFF
These stages are described in the sections that follow, and a complete example can be found on https://github.com/mako-team/ApexScreeningExample .
Creating the post-processing specification
Any Mako development involving Apex requires the Vulkan SDK to be installed. See Apex: Getting started for further details.
This code creates a CCustomColorPostProcessSpec object that is added to the Apex render spec.
Click here to see the C++ code that creates a CCustomColorPostProcessSpec
CPP
// Set some constants
const float resolution = 600.0f; // Render resolution in DPI (dots per inch)
const float lpi = 60.0f; // Screen frequency in LPI (lines per inch)
// Load the precompiled shader (screener.spv) that will apply the halftone screen.
// It uses a push constant to control the angles and dot size, calculated from dpi ÷ lpi.
IApexRenderer::IFragmentShaderPtr shader;
CEDLSimpleBuffer pushConstants;
{
IRAInputStreamPtr shaderStream;
shaderStream = IInputStream::createFromFile(jawsMako, testFilesPath + "screener.spv");
auto halftone = CColorSpotHalftone(resolution, lpi);
pushConstants = halftone.getPushConstantsBuffer();
shaderStream->openE();
int64 length = shaderStream->length();
if (length < 0 || length > INT_MAX)
{
throwEDLError(JM_ERR_GENERAL, L"Error getting shader length, or it's too large!");
}
CEDLSimpleBuffer shaderBuff((uint32_t)length);
shaderStream->completeReadE(&shaderBuff[0], (int32_t)shaderBuff.size());
shaderStream->close();
shader = apex->createFragmentShader(&shaderBuff[0], (uint32_t)shaderBuff.size());
}
At line 12, there is a reference to a class that is defined by the following header. It simplifies creating the push constants, the means by which external code can supply parameters or other information to a shader at runtime.
CPP
/* -----------------------------------------------------------------------
* <copyright file="halftones.h" company="Global Graphics Software Ltd">
* Copyright (c) 2025 Global Graphics Software Ltd. All rights reserved.
* </copyright>
* <summary>
* This example is provided on an "as is" basis and without warranty of any kind.
* Global Graphics Software Ltd. does not warrant or make any representations
* regarding the use or results of use of this example.
* </summary>
* -----------------------------------------------------------------------
*/
#pragma once
#include <jawsmako/jawsmako.h>
using namespace JawsMako;
using namespace EDL;
/**
* @brief A class to represent a color spot halftone with configurable angles and frequency.
*
* This class is used to create a push constant buffer for fragment shaders in the Apex renderer,
* allowing for custom halftone screening of process colors
*
*/
class CColorSpotHalftone
{
public:
/**
* @brief Constructor for CColorSpotHalftone.
*
* @param dpi Resolution in DPI of the raster to be screened.
* @param freq Line frequency in LPI to be used for halftoning.
* @param cyanAngle Angle for the cyan halftone screen in degrees. Default is 15.0 degrees.
* @param magentaAngle Angle for the magenta halftone screen in degrees. Default is 75.0 degrees.
* @param yellowAngle Angle for the yellow halftone screen in degrees. Default is 0.0 degrees.
* @param blackAngle Angle for the black halftone screen in degrees. Default is 45.0 degrees.
*/
CColorSpotHalftone(float dpi,
float freq,
float cyanAngle = 15.0,
float magentaAngle = 75.0f,
float yellowAngle = 0.0f,
float blackAngle = 45.0f) : m_resolution(dpi), m_frequency(freq)
{
m_angles.resize(4);
m_angles[0] = cyanAngle;
m_angles[1] = magentaAngle;
m_angles[2] = yellowAngle;
m_angles[3] = blackAngle;
}
/**
* @brief Create a push constants buffer for the fragment shader
*
* @return @b CEDLSimpleBuffer The push constants for the halftone screen.
*/
CEDLSimpleBuffer getPushConstantsBuffer()
{
auto buffer = CEDLSimpleBuffer(sizeof(float) * 6);
float values[6] = { m_resolution, m_frequency, m_angles[0], m_angles[1], m_angles[2], m_angles[3] };
std::memcpy(&buffer[0], &values, sizeof(values));
return buffer;
}
private:
float m_resolution; //!< The resolution in DPI of the raster to be screened
float m_frequency; //!< The halftone frequency in LPI to be used
CFloatVect m_angles; //!< The angles for the halftone screen in degrees for C, M, Y, K respectively
};
The shader must be compiled before it can be uploaded.
Compiling the shader code
A shader is written in OpenGL Shading Language. See OpenGL Shading Language 101 for a brief primer.
This shader code (a .frag file) is compiled with this command:
glslc -o cmyk_screen.spv .\cmyk_screen.frag
Click here to see the shader code.
C
#version 450
// Input CMYK as a 4-channel image with additive encoding (0 = full ink)
layout(input_attachment_index = 0, binding = 0) uniform subpassInput cmykInput;
// Output: same additive-encoded CMYK
layout(location = 0) out vec4 resultCmyk;
// Push constants for config
layout(push_constant) uniform PushConstants
{
float dpi; // Dots per inch
float lpi; // Lines per inch (screen frequency)
float angleC; // Screen angles (degrees)
float angleM;
float angleY;
float angleK;
int tileX; // Tile offsets (Apex adds these)
int tileY;
} pushConstants;
// Rotate screen space coordinates for each channel
vec2 rotate(vec2 coord, float angleDegrees) {
float angle = radians(angleDegrees);
float s = sin(angle);
float c = cos(angle);
return mat2(c, -s, s, c) * coord;
}
// Screen a single channel
float halftoneChannel(vec2 fragCoord, float angle, float additiveValue, float dotSize)
{
vec2 pt = rotate(fragCoord, angle);
vec2 cell = floor(pt / dotSize);
vec2 center = (cell + 0.5) * dotSize;
float dist = length(pt - center);
float maxRadius = dotSize * 0.7;
float dotRadius = maxRadius * additiveValue;
return dist < dotRadius ? 1.0 : 0.0; // additive (1 = no ink, 0 = ink)
}
void main()
{
vec2 fragCoord = gl_FragCoord.xy + vec2(pushConstants.tileX, pushConstants.tileY);
// Compute dotSize from DPI and LPI
float dotSize = pushConstants.dpi / pushConstants.lpi;
// Load additive CMYK in additive form (1.0 = no ink)
vec4 additiveCmyk = subpassLoad(cmykInput);
// Dot screen each channel
float c = halftoneChannel(fragCoord, pushConstants.angleC, additiveCmyk.r, dotSize);
float m = halftoneChannel(fragCoord, pushConstants.angleM, additiveCmyk.g, dotSize);
float y = halftoneChannel(fragCoord, pushConstants.angleY, additiveCmyk.b, dotSize);
float k = halftoneChannel(fragCoord, pushConstants.angleK, additiveCmyk.a, dotSize);
resultCmyk = vec4(c, m, y, k); // Still in additive form
}
Grayscale rendering
A command-line switch, --gray, will render the input PDF to grayscale.
A similar struct (CSpotHalftone) and a corresponding shader (gray_screen.frag / gray_screen.spv) provide screening of grayscale.
Creating the CRenderSpec
For this example, Apex renders to a buffer, so a CFrameBufferRenderSpec() is required. In this example, the CRenderSpec is created with the process color space and the post-process spec.
Click here to see code that creates the CFrameBufferRenderSpec
CPP
// Set up the render spec - basics
CFrameBufferRenderSpec renderSpec;
renderSpec.processSpace = processColorSpace;
// Add our post process to apply the screen
renderSpec.postProcesses.append(CCustomColorPostProcessSpec::create(
renderSpec.processSpace, renderSpec.processSpace, shader,
IApexRenderer::CTextureVect(), pushConstants));
Subsequently, the render spec is completed prior to rendering in the renderPage()method.
Click here to see the rendering method
CODE
uint32_t width = static_cast<uint32_t>(lround(content->getWidth() / 96.0 * resolution));
uint32_t height = static_cast<uint32_t>(lround(content->getHeight() / 96.0 * resolution));
auto numComponents = renderSpec.processSpace->getNumComponents();
uint32_t stride = width * numComponents;
CEDLSimpleBuffer frameBuffer(static_cast<size_t>(stride) * static_cast<size_t>(height));
// Set up the render spec - basics
renderSpec.width = width;
renderSpec.height = height;
renderSpec.sourceRect = FRect(0.0, 0.0, content->getWidth(), content->getHeight());
renderSpec.buffer = &frameBuffer[0];
renderSpec.rowStride = static_cast<int32_t>(stride);
// Render!
apex->render(content, &renderSpec);
Rendering the result and saving to TIFF
Rendering
Once the render specification is populated, the rendering step is simple:
CPP
// Render!
apex->render(content, &renderSpec);
Encoding to a TIFF
As the image data is in a frame buffer, the TIFF encoder writes the rendered result row-by-row. (This code is found in the renderPage() method.)
Apex can also render to an IDOMImage. Rendering to an IDOMImage is often more convenient; in this case, it would simplify a call to the TIFF or other image encoder. Rendering to a frame buffer is a more flexible approach, particularly when access to the rendered result at a pixel level is required.
Click here to see the TIFF encoding.
CPP
// Write to a tiff
char fileNameBuff[4096];
// Build the file name - we expect %u in the file path.
edlSnprintfE(fileNameBuff, sizeof(fileNameBuff), outputFilePath.c_str(), pageIndex + 1);
// Create a TIFF encoding frame
IImageFrameWriterPtr frame;
(void)IDOMTIFFImage::createWriterAndImage(jawsMako,
frame,
renderSpec.processSpace,
width, height,
8,
resolution, resolution,
IDOMTIFFImage::eTCAuto,
IDOMTIFFImage::eTPNone,
eIECNone,
false,
IInputStream::createFromFile(jawsMako, fileNameBuff),
IOutputStream::createToFile(jawsMako, fileNameBuff));
// Out with it
for (uint32_t y = 0; y < height; y++)
{
frame->writeScanLine(&frameBuffer[y * static_cast<size_t>(stride)]);
}
frame->flushData();