Skip to content

ShadertoyChannels.kt

src/jvmMain/kotlin/afield/graphics/shader/ShadertoyChannels.kt · 357 lines

What a ShaderToy Image pass reads and draws onto in Field: ShadertoyChannel (a texture turned the way ShaderToy samples one), ShadertoyCanvas (the picture a document's shadertoy boxes share, shown in the renderer window) and webcam (the camera as a channel).

shadertoyHere builds the module; these give it inputs and somewhere to draw. The shadertrinket starting point uses all three:

val canvas = shadertoyCanvas()
canvas += shadertoyHere {
    +canvas.rtt
    textures("iChannel0" to webcam())
}
package afield.graphics.shader

import afield.app.diagnostics.logging.lprintln
import afield.document.context.fromContext
import afield.document.graph.Box
import afield.graphics.render.Mesh
import afield.graphics.render.Module
import afield.graphics.render.ModulePointer
import afield.graphics.render.parameters
import afield.graphics.render.pointer
import afield.graphics.texture.HasImageView
import afield.graphics.texture.HasImageViewArray
import afield.graphics.texture.LoadedTexture
import afield.graphics.texture.TextureSampler
import afield.graphics.texture.video.WebcamTexture
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.targets.RenderToTexture
import afield.math.linalg.Vector4f
import afield.math.linalg.vec
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.VK10.*
import java.util.WeakHashMap
import java.util.concurrent.atomic.AtomicInteger

private val channelSerial = AtomicInteger()

internal fun isUnorm8(format: Int): Boolean

True for the 8-bit UNORM colour formats, whose values a shader reads back exactly as stored.

internal fun isUnorm8(format: Int): Boolean = format in setOf(
    VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_R8_UNORM, VK_FORMAT_R8G8_UNORM,
    VK_FORMAT_A8B8G8R8_UNORM_PACK32
)

internal fun sampleableSize(view: Any, fallback: Pair<Int, Int> = 512 to 512): Pair<Int, Int>

The pixel size of something sampleable, when it says: a RenderToTexture or WebcamTexture by type, anything else through a public getWidth()/getHeight() (a WebRTC source has them). fallback otherwise.

internal fun sampleableSize(view: Any, fallback: Pair<Int, Int> = 512 to 512): Pair<Int, Int> {
    when (view) {
        is RenderToTexture -> return view.width to view.height
        is WebcamTexture -> if (view.width > 0) return view.width to view.height
    }
    return runCatching {
        val w = view.javaClass.getMethod("getWidth").invoke(view) as Int
        val h = view.javaClass.getMethod("getHeight").invoke(view) as Int
        if (w > 0 && h > 0) w to h else null
    }.getOrNull() ?: fallback
}

class ShadertoyChannel private constructor

A texture as a ShaderToy iChannel sees it: a copy, redrawn every frame, that is bottom-up and holds display values.

Field textures hold their rows top-down (camera frames, browser pages, and render targets, because of the renderer's negative-height viewport), while a ShaderToy program samples fragCoord / iResolution.xy with y up, so the picture would come out upside down. And a ShaderToy program reads a camera's sRGB-encoded numbers, while sampling Field's _SRGB textures decodes them to linear light. GLSL can neither overload texture nor wrap a sampler (programs pass samplers to their own functions), so the fix is a small pass per channel: a full-screen draw into an RGBA8 UNORM target of the source's size that flips the rows and re-encodes anything that is not already 8-bit UNORM. It is registered as a renderer pre-target, so it runs before the frame's passes, after any pre-target already there (a WebcamTexture's upload).

One channel is kept per source and renderer (of), so re-running a box reuses it; nothing frees a render target, so it lives as long as its source.

class ShadertoyChannel private constructor(
    /**
     * The renderer this channel's flip pass runs on, and the first half of the key it is cached
     * under: one channel per renderer and per source view.
     */
    val renderer: Renderer,
    source: Pair<HasImageViewArray, TextureSampler>,
    /** The source's width in pixels (and so this channel's `iChannelResolution`). */
    val width: Int,
    /** The source's height in pixels. */
    val height: Int,
    sourceFormat: Int
) {

val target

The flipped copy, which the shadertoy module samples.

    val target = RenderToTexture(renderer) {
        this.width = this@ShadertoyChannel.width
        this.height = this@ShadertoyChannel.height
        format = VK_FORMAT_R8G8B8A8_UNORM
        useDepth = false
        colorSampled = true
        clearColor = vec(0f, 0f, 0f, 1f)
        debugName = "shadertoy channel ${channelSerial.incrementAndGet()}"
    }

val sampler: TextureSampler = target.setDefaultSampler(anisotropyEnable = false)

Linear filtering, clamped at the edges.

    private val module: ModulePointer = Module(parameters(renderer) {
        name = target.parameters.debugName
        vertex = FULLSCREEN_VERTEX
        fragment = flipFragment(encode = !isUnorm8(sourceFormat), height = this@ShadertoyChannel.height)
        blend = false
        depthTest = false
        depthWrite = false
        +target
        textureResolver = { if (it.name == "source") source else null }
        errorHandler = { message, _ -> lprintln("error,graphics", "shadertoy channel: $message") }
    }).pointer()

init

    init {
        val mesh = Mesh(renderer, module)
        mesh.resize(3, 0, module)
        module.current.meshes["fullscreen"] = mesh
        target += "flip" to module
        renderer.renderList.preTargets[target.parameters.debugName] = target
    }

companion object

    companion object {
        private val cache = WeakHashMap<Renderer, WeakHashMap<HasImageView, ShadertoyChannel>>()

fun of(renderer: Renderer, source: Pair<HasImageViewArray, TextureSampler>): ShadertoyChannel

The channel for the first view of source (what a texture resolver returned), made on first use and kept for as long as that view lives.

        fun of(renderer: Renderer, source: Pair<HasImageViewArray, TextureSampler>): ShadertoyChannel {
            val view = source.first.imageViewArray.first()
            synchronized(cache) {
                val perRenderer = cache.getOrPut(renderer) { WeakHashMap() }
                perRenderer[view]?.let { return it }
                val (w, h) = sampleableSize(view)
                val format = view.imageView.imageViewData.format
                // Forward layout preparation for sources that need it (a browser page's texture).
                val forwarded = fromViews(listOf(view), "source", source.second)
                return ShadertoyChannel(renderer, forwarded, w, h, format).also { perRenderer[view] = it }
            }
        }

internal const val FULLSCREEN_VERTEX = """#version 450

void main() {
    vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
    gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
}
"""

internal fun flipFragment(encode: Boolean, height: Int)

gl_FragCoord rather than an interpolated uv: it starts at the top left whatever the viewport convention, so "row 0 of the copy is the source's bottom row" is written down directly.

        internal fun flipFragment(encode: Boolean, height: Int) = """#version 450
layout(location = 0) out vec4 color;
layout(set = 0, binding = 0) uniform sampler2D source;

vec3 linearToDisplay(vec3 c) {
    c = clamp(c, 0.0, 1.0);
    return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}

void main() {
    vec2 size = vec2(textureSize(source, 0));
    vec4 c = texture(source, vec2(gl_FragCoord.x / size.x, 1.0 - gl_FragCoord.y / float($height)));
    color = vec4(${if (encode) "linearToDisplay(c.rgb)" else "c.rgb"}, 1.0);
}
"""
    }
}

class ShadertoyCanvas internal constructor

The picture a document's shadertoy boxes draw into, like ShaderTrinket's canvas: a width×height render target, shown letterboxed in the renderer window, that can also be published or bound as another shader's iChannel (it is rtt).

canvas += module draws a shadertoyHere module into it (build the module with +canvas.rtt in its block) and makes the module's iResolution the canvas size and its iMouse the window cursor mapped into canvas pixels. Every box adds under the same name, so the last one launched is the one showing, the way picking a preset in ShaderTrinket replaces the program.

Get one with shadertoyCanvas; it is kept per renderer and name, so every box (and every re-run) shares it. A canvas that is only an input for another shader passes inWindow = false.

class ShadertoyCanvas internal constructor(
    /**
     * The renderer that holds the canvas and composites it. Its window supplies the aspect the
     * canvas is letterboxed into and the cursor that becomes `iMouse`.
     */
    val renderer: Renderer,
    /**
     * What this canvas is called, `shadertrinket` by default. Canvases are shared per renderer and
     * per name, so two boxes asking for the same name draw into the same picture; it also names the
     * render target and the compositing module in the renderer's lists.
     */
    val name: String,
    /**
     * The canvas's width in pixels, and the x of `iResolution` for every module drawn into it. Fixed
     * at first use: asking for the same name at another size keeps this one and logs a warning.
     */
    val width: Int,
    /** The canvas's height in pixels, fixed at first use in the same way as the width. */
    val height: Int,
    inWindow: Boolean
) {

val rtt

The canvas itself: _SRGB, so the display values a ShaderToy program writes are what it stores.

    val rtt = RenderToTexture(renderer) {
        this.width = this@ShadertoyCanvas.width
        this.height = this@ShadertoyCanvas.height
        format = VK_FORMAT_B8G8R8A8_SRGB
        useDepth = false
        colorSampled = true
        mipLevels = 1
        clearColor = vec(0f, 0f, 0f, 1f)
        debugName = "shadertoy canvas $name"
    }

    private val sampler = rtt.setDefaultSampler(anisotropyEnable = false)

fun fitInWindow(): Vector4f

The canvas's rectangle in the window, as fractions (x, y, w, h) from the top left.

    fun fitInWindow(): Vector4f {
        val window = renderer.window
        val aw = window.width.toDouble() / window.height.coerceAtLeast(1)
        val ac = width.toDouble() / height
        val fx = if (aw > ac) ac / aw else 1.0
        val fy = if (aw > ac) 1.0 else aw / ac
        return Vector4f(((1 - fx) / 2).toFloat(), ((1 - fy) / 2).toFloat(), fx.toFloat(), fy.toFloat())
    }

val surface

iResolution and iMouse for modules drawn into this canvas.

    val surface = ShadertoySurface(
        size = { width to height },
        pointer = {
            val window = renderer.window
            val points = window.size()
            val c = window.cursor
            val fit = fitInWindow()
            val u = (c.x / points.w.coerceAtLeast(1.0) - fit.x) / fit.z
            val v = (c.y / points.h.coerceAtLeast(1.0) - fit.y) / fit.w
            val down = window.isMouseButtonPressed(org.lwjgl.glfw.GLFW.GLFW_MOUSE_BUTTON_LEFT)
            if (u < 0 || u > 1 || v < 0 || v > 1) null
            else ((u * width).toFloat() to ((1 - v) * height).toFloat()) to down
        }
    )

    private val composite: ModulePointer = Module(parameters(renderer) {
        this.name = "shadertoy canvas $name (window)"
        vertex = ShadertoyChannel.FULLSCREEN_VERTEX
        fragment = COMPOSITE_FRAGMENT
        blend = false
        depthTest = false
        depthWrite = false
        textureResolver = { if (it.name == "canvas") fromViews(listOf(rtt), "canvas", sampler) else null }
        errorHandler = { message, _ -> lprintln("error,graphics", "shadertoy canvas: $message") }
    }).pointer()

init

    init {
        val mesh = Mesh(renderer, composite)
        mesh.resize(3, 0, composite)
        mesh.push.bind("fit") { fitInWindow() }
        mesh.push.bind("window") { Vector4f(renderer.window.width.toFloat(), renderer.window.height.toFloat(), 0f, 0f) }
        composite.current.meshes["fullscreen"] = mesh
        renderer.renderList.preTargets[rtt.parameters.debugName] = rtt
        composite.current.enabled = inWindow
        renderer.renderList.modules[composite.current.parameters.name] = composite
    }

var inWindow: Boolean

Whether the window shows this canvas. Turn it off for a canvas that is only another shader's channel.

    var inWindow: Boolean
        get() = composite.current.enabled
        set(value) { composite.current.enabled = value }

operator fun plusAssign(module: ModulePointer)

Draw module (a shadertoyHere module built with +canvas.rtt) as this canvas's picture.

    operator fun plusAssign(module: ModulePointer) {
        val channels = synchronized(shadertoyChannelsByModule) { shadertoyChannelsByModule[module] } ?: arrayOfNulls(4)
        bindShadertoyUniforms(module, surface, channels)
        rtt += "shader" to module
    }

    private companion object {
        const val COMPOSITE_FRAGMENT = """#version 450
layout(location = 0) out vec4 color;
layout(set = 0, binding = 0) uniform sampler2D canvas;
layout(push_constant) uniform Fit { vec4 fit; vec4 window; } pc;

void main() {
    // Both measured from the top left: gl_FragCoord in the window, and the canvas's rows.
    vec2 c = (gl_FragCoord.xy / pc.window.xy - pc.fit.xy) / pc.fit.zw;
    if (any(lessThan(c, vec2(0.0))) || any(greaterThan(c, vec2(1.0)))) color = vec4(0.0, 0.0, 0.0, 1.0);
    else color = vec4(texture(canvas, c).rgb, 1.0);
}
"""
    }
}

private val canvases = WeakHashMap<Renderer, MutableMap<String, ShadertoyCanvas>>()

fun shadertoyCanvas

This renderer's shadertoy canvas called name, made width×height on first use and shared after that (asking again with another size keeps the first, and says so). See ShadertoyCanvas.

fun shadertoyCanvas(
    width: Int = 960,
    height: Int = 540,
    name: String = "shadertrinket",
    inWindow: Boolean = true,
    renderer: Renderer = fromContext()
): ShadertoyCanvas = synchronized(canvases) {
    val perRenderer = canvases.getOrPut(renderer) { mutableMapOf() }
    perRenderer[name]?.let {
        if (it.width != width || it.height != height)
            lprintln("warning,graphics", "shadertoy canvas '$name' is already ${it.width}x${it.height}; keeping it")
        it.inWindow = inWindow
        return it
    }
    ShadertoyCanvas(renderer, name, width, height, inWindow).also { perRenderer[name] = it }
}

private val webcams = WeakHashMap<Renderer, MutableMap<Int, WebcamTexture>>()
private val noCamera = WeakHashMap<Renderer, LoadedTexture>()

fun Box.webcam(device: Int = 0, width: Int = 1280, height: Int = 720, renderer: Renderer = fromContext()): HasImageView

The camera device as a texture, for textures("iChannel0" to webcam()): opened once per renderer at width×height (if the camera offers it) and kept open across re-runs, and uploaded every frame as a renderer pre-target.

With no camera, or no permission to use one, this box prints why and the channel is black, which is what ShaderTrinket's Camera Input Demo shows with its camera off. The next run tries again.

fun Box.webcam(device: Int = 0, width: Int = 1280, height: Int = 720, renderer: Renderer = fromContext()): HasImageView {
    synchronized(webcams) {
        webcams[renderer]?.get(device)?.let { return it }
        return try {
            val camera = WebcamTexture(renderer, device, width, height).start()
            webcams.getOrPut(renderer) { mutableMapOf() }[device] = camera
            renderer.renderList.preTargets["webcam $device"] = camera
            camera
        } catch (t: Throwable) {
            val reason = generateSequence(t) { it.cause }.last().message ?: t.toString()
            println("webcam(): no camera $device ($reason), so its channel is black. On macOS, allow Field (or the Terminal that started it) in System Settings > Privacy & Security > Camera, then run this box again.")
            noCamera.getOrPut(renderer) {
                val data = MemoryUtil.memAlloc(4)
                data.put(byteArrayOf(0, 0, 0, 255.toByte()))
                data.flip()
                LoadedTexture.fromRawRGBA("no camera", 1, 1, data, renderer, VK_FORMAT_R8G8B8A8_UNORM)
            }
        }
    }
}