Skip to main content
Skip table of contents

Post-processing example - Applying a conventional line screen

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

    • Requires first compiling the shader to a .spv (SPIR-V) file that Apex can use

  • 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();

JavaScript errors detected

Please note, these errors can depend on your browser setup.

If this problem persists, please contact our support.