Skip to content

Repository files navigation

glslint

A luma.gl / deck.gl-aware GLSL checker and language server. Stock GLSL tools choke on these shaders because they aren't standalone translation units: they reference UBO instances (wind.*, blit.*) declared in separate module fragments and deck builtins (project_position_to_clipspace) injected at link time. glslint assembles the modules + deck stubs into a complete unit, validates it with the Khronos glslangValidator reference compiler, and maps diagnostics back to the original file and line.

Requirements

glslint shells out to glslangValidator (the Khronos GLSL reference compiler). It is not bundled, so install it once:

brew install glslang            # macOS (provides glslangValidator and the newer `glslang`)
sudo apt install glslang-tools  # Debian / Ubuntu

On Windows it ships with the Vulkan SDK. glslint finds it on PATH (trying glslangValidator, then glslang); set GLSLINT_GLSLANG to point at a specific binary. When it is missing, glslint reports the install command for the platform it is running on rather than a bare "not found".

Install

cargo install glslint                    # Rust toolchain
npm install --save-dev @glslint/cli      # prebuilt binary, no Rust toolchain

The npm package pulls a prebuilt binary through a per-platform optional dependency (@glslint/darwin-arm64 and friends), so a deck.gl project downloads one binary and needs no Rust. It installs a plain glslint command, so the scope only appears at install time. Prebuilt for macOS arm64 and x64, Linux x64, and Windows x64; anything else uses the cargo route. The packaging lives in npm/.

The unscoped npm name glslint is unavailable: npm's typosquatting filter rejects it as too close to glslify, eslint, tslint, and dtslint. Hence the scope.

Usage

cargo build
./target/debug/glslint check path/to/shader.frag.glsl   # one-shot, exit 1 on errors
./target/debug/glslint check 'src/**/*.ts'               # lint shaders embedded in JS/TS
./target/debug/glslint lsp                               # language server over stdio

check resolves config by walking up for a glsl-lsp.toml; with none, it uses a built-in deck project32 prelude and auto-discovers sibling *Uniforms.glsl module fragments next to the target. A .ts/.tsx/.js/.jsx argument is scanned for GLSL embedded in tagged template literals instead of being treated as one shader (see below).

The faithful form mirrors luma's own model: name the modules once, then bind each shader to the modules it actually uses (the new Model({modules: [...]}) call in JS). Each shader then gets exactly its modules, so referencing a uniform block the shader doesn't have is flagged instead of silently resolved:

# glsl-lsp.toml
[[module]]
name = "windUniforms"
source = "src/shaders/windUniforms.glsl"
types  = "src/modules.ts"  # optional: cross-check the UBO block vs JS uniformTypes

[[module]]
name = "project32"
builtin = true            # deck project32 (baked-in stub for now)

[[shader]]
match   = "draw.*.glsl"   # first matching binding wins
modules = ["project32", "windUniforms"]

[[shader]]
match   = "blit.*.glsl"
modules = ["blitUniforms"]

Without [[shader]] bindings it accepts a legacy global list (preludes / modules / builtin_prelude) applied to every shader. With no glsl-lsp.toml at all, it first tries to auto-derive each shader's modules from the project's JS/TS new Model({ modules }) calls (see derive.rs), and only falls back to zero-config sibling discovery if that finds nothing.

Shaders inside JS/TS

deck.gl/luma.gl's other common shape is a shader written inline next to its new Model(...) call as a tagged template, rather than imported from a .glsl file with ?raw. glslint lints those in place. Both the glsl tag and the editor-marker form `/* glsl */ `… are recognized in .ts/.tsx/.js/.jsx/.mts/.cts/.mjs/.cjs files:

const fs = glsl`#version 300 es
precision highp float;
out vec4 fragColor;
void main() { fragColor = vec4(nope, 1.0); }
`;
// error lands on the .ts file: draw.ts:4:32: 'nope' : undeclared identifier

Each template's diagnostics — the glslang errors and the source lints alike — are mapped back to the exact line and column in the host .ts file, so an error on the fourth line of the shader lands on the fourth line of the shader as written in the TypeScript. Multiple templates per file are handled independently, the stage is inferred from the shader's text and its binding name (a gl_Position write ⇒ vertex; a main with neither ⇒ fragment; no main ⇒ a chunk checked syntax-only), and when the shader is assigned to a binding that a new Model({ modules }) call wires up, its luma modules are resolved and injected the same way derive.rs does for ?raw imports.

Interpolations. A template containing a ${…} interpolation isn't a complete translation unit — the injected value can declare symbols the shader uses, or use ones it declares, and glslint can't see either. Validating a shader it can't faithfully reconstruct would risk reporting errors that are really the tool's fault, so such a template is checked with the source-level lints only (those match the author's own tokens and don't need a complete unit) and gets one informational note. Templates with no interpolation are fully validated. The ${…} is blanked to spaces — newlines preserved — purely so the lints' line/column math stays exact.

Editor integration

A minimal VS Code / Cursor extension lives in editors/vscode/. It's a thin LSP client that launches glslint lsp and shows its diagnostics on GLSL files — and on shaders embedded in .ts/.js tagged templates, mapped live to the TypeScript line as you type. See that folder's README to run it (F5 dev host, or vsce package). Point glslint.path at the built binary (e.g. target/debug/glslint) if it isn't on the editor's PATH.

How it works

  • assemble.rs: hoists the target's own #version to the top, injects default precision (so the deck prelude's float/vec* are well-formed before the shader's own precision line), then the prelude + module blocks, recording a per-line map back to the originals. Source is passed through verbatim; glslangValidator validates GLSL ES natively, so there are no source transforms. Stage is inferred from the filename (*.vert.glsl / *.frag.glsl / *.comp.glsl); bare module fragments are wrapped in a dummy shell for syntax-only checking.
  • check.rs: runs glslangValidator --stdin -S <stage> over the assembled unit, parses its ERROR: 0:LINE: / WARNING: output, collapses glslang's per-line error cascades to the root cause, and translates each line back to the original file:line via the map (refining the column from the offending token when glslang names one). This mapping is the hard part and it works: errors land on draw.vert.glsl:4, and an error inside an injected module lands on windUniforms.glsl:3, not the assembled unit.
  • lints.rs: opinionated, zero-false-positive rules (currently: GLSL ES 1.00 builtins/qualifiers removed in #version 300 es). Runs alongside the validator, so a varying declaration draws both the raw glslang error and a friendlier migration hint.
  • drift.rs: when a module declares a types JS file (see config), cross-checks its GLSL UBO block against that file's uniformTypes and warns on drift (a member on one side only, or a type mismatch). luma keeps these two in sync by hand; nothing else sees both at once. Conservative: silent unless it can confidently read both sides.
  • symbols.rs: a line-based symbol scanner over the assembled unit (UBO/interface blocks, top-level uniform/in/out, function definitions, deck builtins), each symbol carrying its original Loc. Powers hover, go-to-definition, completion, and the document outline, including the cross-module jump: wind.uMin in draw.vert.glsl resolves to its declaration in windUniforms.glsl.
  • deck.rs: resolves deck.gl's project builtins from node_modules instead of a hand-written stub. deck ships the module GLSL as a JS template whose bodies interpolate constants and depend on a geometry/project UBO, so the bodies can't be spliced; but the function signatures are clean GLSL. So it extracts the real signatures, generates empty-body stubs so any project function validates (no dependency graph needed), and records each declaration site, so hover shows the true signature and go-to-definition jumps into the deck source. Falls back to a baked-in 4-function stub when deck isn't installed.
  • derive.rs: when there's no glsl-lsp.toml, recovers each shader's module bindings the way luma already encodes them: by reading the project's new Model({ vs, fs, modules }) calls. It finds the shader's ?raw import, the Model call that references it, and follows each module identifier to its GLSL source (a local module's vs: import, e.g. windUniformswindUniforms.glsl) or to the deck builtins (a package import like project32). A heuristic scan, not a JS parser: handles multi-line imports, conservative enough to fall back to sibling discovery when it can't confidently resolve. The payoff: deck-wind-layer's shaders validate with their faithful per-shader modules and no config file. blit bound to blitUniforms only, draw to project32 + windUniforms. It also exposes derive_for_binding, which resolves modules for an embedded shader whose binding comes from a const vs = glsl…`` rather than a ?raw import.
  • embed.rs: extracts GLSL from JS/TS tagged template literals (glsl and `/* glsl */ `…) so a .ts/.js file can be checked in place. A heuristic scan like derive.rs, it tracks strings/comments so a backtick in a "…" string — or glsl inside a longer identifier — isn't mistaken for a shader, and it records, per line of reconstructed GLSL, where that line begins in the host file. That per-line host map is what check.rs composes with the assembler's own map so a shader error lands on the right .ts line and column (the assembler maps the shader → its snippet line; embed offsets the snippet line → the template's span). Line continuations (\+newline) are applied and ${…} interpolations are blanked to spaces (newlines preserved); a template with any interpolation is checked lints-only, since its translation unit is incomplete.
  • lsp.rs: tower-lsp; publishDiagnostics on open/change/save, plus hover, go-to-definition, completion (wind. → member list), and document symbols, filtered to the edited document. It runs for JS/TS documents too, publishing the embedded-shader diagnostics (the GLSL-only symbol features stay on .glsl files). Edits are debounced and the (subprocess-spawning) check runs off the async runtime, with a per-document generation guard so a slow check can't clobber a newer edit.

Why glslangValidator

The validation engine was originally naga (front::glsl), chosen for in-process Rust with no external dependency. Spikes proved naga's GLSL frontend is a Vulkan-GLSL frontend, not a WebGL/OpenGL one:

Construct naga front::glsl glslangValidator
#version 300 es rejected (accepts only 440/450/460 core) OK
precision highp float; rejected OK
layout(std140) uniform {…} w/o binding rejected (requires binding=) OK
combined sampler2D u; decl rejected (wants separate texture2D + sampler) OK
sampler2D as a function parameter rejected: Expected RightParen OK

That last row was the blocker: deck-wind-layer's two main shaders use

vec2 windAt(sampler2D windTex, vec2 pos) { … texture(windTex, pos) … }

which naga can't express, and supporting it would have meant rewriting function signatures, internal texture() calls, and every call site: scope-aware compiler work. glslangValidator is the Khronos ES reference compiler: it validates #version 300 es + combined samplers authoritatively, with zero source transforms. The cost is an external binary on PATH; the assembler, the diagnostic mapping, the CLI, the LSP, and the lints are all backend-independent and carried over unchanged.

glslang quirks worth knowing

  • All diagnostics go to stdout (not stderr), as ERROR: 0:LINE: 'token' : message. The 0 is the source-string index (always 0 for our single stdin unit); there's no column, so glslint derives one from the named token.
  • glslang does not stop at the first error. For a semantic failure it emits the root cause and then a string of derived errors (sometimes exact duplicates) on the same source line, and it inlines whole type definitions into some messages (an entire uniform block{...} for a bad UBO-member access). glslint therefore keeps the first diagnostic per source line (glslang emits the root cause first) and truncates over-long messages. The compilation terminated line (a parse-phase cascade only) and the N compilation errors summary are filtered out separately.
  • GLSL ES fragment shaders have no default float precision, so the assembler injects precision highp float; precision highp int; right after #version; re-declaring them later (as the shaders do) is legal.

License

Dual-licensed under either MIT or Apache-2.0, at your option.

About

luma.gl/deck.gl-aware GLSL checker + language server

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages