From 9a86ee6448f1da09db5aec0e5feeb9b931a4df2f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 3 Jul 2026 08:28:04 +0530 Subject: [PATCH] Generate all cell shader variations at build time Trying to generate them at runtime is fragile if using slang IR as the IR is highly version dependent. If going from source it means it is even slower and would require some sophisticated caching scheme. --- kitty/shaders/cell.slang | 24 +++--- kitty/shaders/slang.py | 179 ++++++++++----------------------------- 2 files changed, 56 insertions(+), 147 deletions(-) diff --git a/kitty/shaders/cell.slang b/kitty/shaders/cell.slang index be379b85b..2b5632ebd 100644 --- a/kitty/shaders/cell.slang +++ b/kitty/shaders/cell.slang @@ -12,13 +12,10 @@ import linear2srgb; #define NUM_COLORS 256 -extern static const bool DO_FG_OVERRIDE; extern static const uint FG_OVERRIDE_ALGO; -extern static const float FG_OVERRIDE_THRESHOLD; extern static const bool TEXT_NEW_GAMMA; extern static const bool ONLY_FOREGROUND; extern static const bool ONLY_BACKGROUND; -static const bool OVERRIDE_FG_COLORS = DO_FG_OVERRIDE == 1 && !ONLY_BACKGROUND; // Inputs {{{ struct CellRenderDataStruct @@ -343,20 +340,20 @@ float calc_background_opacity(uint bg) { } // Override foreground colors {{{ -float3 fg_override_luminance(float colored_sprite, float under_luminance, float over_lumininace, float3 under, float3 over) { +float3 fg_override_luminance(float colored_sprite, float under_luminance, float over_lumininace, float3 under, float3 over, float fg_override_thresold) { // If the difference in luminance is too small, // force the foreground color to be black or white. float diff_luminance = abs(under_luminance - over_lumininace); - float override_level = (1.f - colored_sprite) * step(diff_luminance, FG_OVERRIDE_THRESHOLD); + float override_level = (1.f - colored_sprite) * step(diff_luminance, fg_override_thresold); float original_level = 1.f - override_level; return original_level * over + override_level * float3(step(under_luminance, 0.5f)); } -float3 fg_override_contrast(float under_luminance, float over_luminance, float3 under, float3 over) { +float3 fg_override_contrast(float under_luminance, float over_luminance, float3 under, float3 over, float fg_override_thresold) { float ratio = contrast_ratio(under_luminance, over_luminance); float3 diff = abs(under - over); float3 over_hsluv = rgbToHsluv(over); - const float min_contrast_ratio = FG_OVERRIDE_THRESHOLD; + const float min_contrast_ratio = fg_override_thresold; float target_lum_a = clamp((under_luminance + 0.05) * min_contrast_ratio - 0.05, 0.0, 1.0); float target_lum_b = clamp((under_luminance + 0.05) / min_contrast_ratio - 0.05, 0.0, 1.0); float3 result_a = clamp(hsluvToRgb(float3(over_hsluv.x, over_hsluv.y, target_lum_a * 100.0)), 0.0, 1.0); @@ -368,11 +365,11 @@ float3 fg_override_contrast(float under_luminance, float over_luminance, float3 return lerp(result, over, fallback_condition); } -float3 override_foreground_color(float3 over, float3 under, float colored_sprite) { +float3 override_foreground_color(float3 over, float3 under, float colored_sprite, float fg_override_thresold) { float under_luminance = dot(under, Y); float over_lumininace = dot(over.rgb, Y); - if (FG_OVERRIDE_ALGO == 1) return fg_override_luminance(colored_sprite, under_luminance, over_lumininace, under, over); - return fg_override_contrast(under_luminance, over_lumininace, under, over); + if (FG_OVERRIDE_ALGO == 1) return fg_override_luminance(colored_sprite, under_luminance, over_lumininace, under, over, fg_override_thresold); + return fg_override_contrast(under_luminance, over_lumininace, under, over, fg_override_thresold); } // }}} @@ -383,6 +380,7 @@ VertexOutput vertex_main( uint instance_id : SV_InstanceID, uniform uint draw_bg_bitfield, uniform float row_offset, + uniform float fg_override_thresold, ) { VertexOutput vo; @@ -452,9 +450,9 @@ VertexOutput vertex_main( vo.background = background_rgb; // }}} - if (!ONLY_BACKGROUND && OVERRIDE_FG_COLORS) { - vo.decoration_fg = override_foreground_color(vo.decoration_fg, background_rgb, vo.colored_sprite); - foreground = override_foreground_color(foreground, background_rgb, vo.colored_sprite); + if (!ONLY_BACKGROUND && FG_OVERRIDE_ALGO > 0) { + vo.decoration_fg = override_foreground_color(vo.decoration_fg, background_rgb, vo.colored_sprite, fg_override_thresold); + foreground = override_foreground_color(foreground, background_rgb, vo.colored_sprite, fg_override_thresold); } if (!ONLY_FOREGROUND) { diff --git a/kitty/shaders/slang.py b/kitty/shaders/slang.py index 0c601d1e0..a0edc76cb 100644 --- a/kitty/shaders/slang.py +++ b/kitty/shaders/slang.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2026, Kovid Goyal -import fcntl +import hashlib import json import os import re @@ -10,10 +10,10 @@ import shutil import sys import time from collections import OrderedDict -from contextlib import contextmanager, suppress +from contextlib import suppress from enum import StrEnum from functools import lru_cache -from itertools import chain +from itertools import chain, product from pathlib import Path from types import MappingProxyType from typing import Any, Callable, Iterable, Iterator, NamedTuple @@ -33,8 +33,6 @@ from kitty.fast_data_types import ( MARK_MASK, REVERSE, STRIKETHROUGH, - get_boss, - get_options, ) from kitty.options.types import Options, defaults @@ -99,6 +97,40 @@ class Specialization(NamedTuple): return f'.{self.name}' if self.name else '.default-specialization' +def cell_variant(opts: Options = defaults, only_fg: bool = False, only_bg: bool = False) -> dict[str, str]: + text_fg_override_threshold: float = opts.text_fg_override_threshold[0] + algo = '0' + match opts.text_fg_override_threshold[1]: + case '%': + text_fg_override_threshold = max(0, min(text_fg_override_threshold, 100.0)) * 0.01 + algo = '1' + case 'ratio': + text_fg_override_threshold = max(0, min(text_fg_override_threshold, 21.0)) + algo = '2' + return { + 'FG_OVERRIDE_ALGO': algo, + 'TEXT_NEW_GAMMA': 'false' if opts.text_composition_strategy == 'legacy' else 'true', + 'ONLY_FOREGROUND': 'true' if only_fg else 'false', + 'ONLY_BACKGROUND': 'true' if only_bg else 'false', + } + + +@lru_cache(maxsize=2) +def cell_variations() -> tuple[MappingProxyType[str, str], ...]: + variations = {'FG_OVERRIDE_ALGO': ('0', '1', '2')} + bool_variations = 'false', 'true' + variants_dict = {k: variations.get(k, bool_variations) for k in cell_variant()} + return tuple(MappingProxyType(dict(zip(variants_dict.keys(), comb))) for comb in product(*variants_dict.values())) + + +def variant_name(variant: dict[str, str], default: dict[str, str]) -> str: + if variant == default: + return '' + data = ' '.join(f'{k}={variant[k]}' for k in sorted(default)).encode() + key = hashlib.md5(data, usedforsecurity=False) + return key.hexdigest()[:5] + + class SlangFile(NamedTuple): path: str = '' text: str = '' @@ -108,8 +140,6 @@ class SlangFile(NamedTuple): specializable_variables: MappingProxyType[str, str] = MappingProxyType({}) disable_warnings: frozenset[str] = frozenset() - opts: Options | None = None - def asdict(self, skip_source: bool = False) -> dict[str, Any]: ' Return a dict useable for serialization to JSON ' ans = self._asdict() @@ -120,7 +150,6 @@ class SlangFile(NamedTuple): if skip_source: ans['text'] = '' ans['path'] = os.path.basename(ans['path']) - del ans['opts'] return ans @classmethod @@ -153,9 +182,6 @@ class SlangFile(NamedTuple): ans['COLOR_IS_RGB'] = str(COLOR_IS_RGB) return MappingProxyType(ans) - def get_options(self) -> Options: - return self.opts or defaults - @property def specializations(self) -> Iterator[Specialization]: def s(name: str = '', **kwargs: str) -> Specialization: @@ -167,29 +193,14 @@ class SlangFile(NamedTuple): yield s('alpha_mask', is_alpha_mask='true') yield s('premult', texture_is_not_premultiplied='true') case 'cell.slang': - opts = self.get_options() - text_fg_override_threshold: float = opts.text_fg_override_threshold[0] - match opts.text_fg_override_threshold[1]: - case '%': - text_fg_override_threshold = max(0, min(text_fg_override_threshold, 100.0)) * 0.01 - algo = '1' - case 'ratio': - text_fg_override_threshold = max(0, min(text_fg_override_threshold, 21.0)) - algo = '2' - base = {k:str(v) for k, v in dict( - DO_FG_OVERRIDE='true' if text_fg_override_threshold else 'false', - FG_OVERRIDE_ALGO=algo, - FG_OVERRIDE_THRESHOLD=text_fg_override_threshold, - TEXT_NEW_GAMMA='false' if opts.text_composition_strategy == 'legacy' else 'true', - ONLY_FOREGROUND='false', - ONLY_BACKGROUND='false', - ).items()} - yield s('', **base) - base['ONLY_FOREGROUND'] = 'true' - yield s('fg', **base) - base['ONLY_FOREGROUND'] = 'false' - base['ONLY_BACKGROUND'] = 'true' - yield s('bg', **base) + d = cell_variant() + seen = set() + for variant in cell_variations(): + name = variant_name(dict(variant), d) + if name in seen: + raise Exception('Variant names for cell shader not unique') + seen.add(name) + yield s(name, **variant) case _: yield s() @@ -575,106 +586,6 @@ def compile_builtin_shaders(build_dir: str, dest_dir: str, parallel_run: Paralle parallel_run((True, f'Validating |{os.path.basename(x)}| ...', validation_command_for_file(x)) for x in built_glsl_files) -@contextmanager -def lock_directory(target_dir: str) -> Iterator[None]: - ''' - Context manager to exclusively lock a directory using a hidden lock file. - Works across all Unix-like operating systems. - ''' - os.makedirs(target_dir, exist_ok=True) - lock_file_path = os.path.join(target_dir, '.shaders.lock') - lock_fd = os.open(lock_file_path, os.O_CREAT | os.O_WRONLY) - try: - fcntl.flock(lock_fd, fcntl.LOCK_EX) - yield - finally: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - os.close(lock_fd) - - -def run_commands(cmds: Iterable[Command], cwd: str | None = None) -> None: - import subprocess - workers = [] - for c in cmds: - if c.needs_build: - try: - p = subprocess.Popen(c.cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, cwd=cwd) - except FileNotFoundError: - raise Exception(f'Could not find slangc compiler ({slangc}) in PATH: {os.environ.get("PATH")}') - workers.append((c, p)) - errors = [] - for (c, p) in workers: - if p.wait() != 0: - assert p.stderr is not None - stderr = p.stderr.read().decode('utf-8', 'replace') - errors.append((c, stderr)) - if errors: - raise Exception(f'Compiling shader failed. Command that was run: {errors[0][0]}\n{errors[0][1]}') - - -def specialize_shaders_to(sources: dict[str, SlangFile], dest_dir: str) -> None: - for name in sources: - shutil.copy2(os.path.join(shaders_dir, f'{name}.slang-module'), dest_dir) - specialisation_cmds = create_specialisations(sources, dest_dir) - run_commands(specialisation_cmds, dest_dir) - _ = [] - spirv = commands_to_compile_to_spirv(sources, dest_dir, _) - glsl_built_files: list[str] = [] - glsl = commands_to_compile_to_glsl(sources, dest_dir, glsl_built_files) - run_commands(chain(spirv, glsl), dest_dir) - fixup_opengl_files(*glsl_built_files) - - -@lru_cache(maxsize=2) -def per_process_cache_dir() -> str: - ' A dir that has the lifetime of this process ' - import atexit - from tempfile import mkdtemp - ans = mkdtemp() - boss = get_boss() - try: - boss.atexit.rmtree(ans) - except Exception: # happens if no boss exists - atexit.register(shutil.rmtree, ans) - return ans - - -specialize_cache: dict[str, tuple[tuple[Specialization, ...], dict[str, bytes]]] = {} - - -def specialize_cell_shader( - create_cache_dir: Callable[[], str] = per_process_cache_dir, - opts: Options | None = None -) -> dict[str, bytes]: - ' Specialize the cell shader based on the specified options ' - with open(os.path.join(shaders_dir, 'cell.json')) as f: - builtin_sfile = SlangFile.fromdict(json.load(f)) - d = builtin_sfile._asdict() - if opts is None: - with suppress(RuntimeError): - opts = get_options() - d['opts'] = opts - sfile = SlangFile(**d) - builtin, current = tuple(builtin_sfile.specializations), tuple(sfile.specializations) - if builtin == current: # options not changed from defaults - return {} - dest_dir = create_cache_dir() - cache_key = f'cell-{dest_dir}' - if (cx := specialize_cache.get(cache_key)) and cx[0] == current: - return cx[1] - - with lock_directory(dest_dir): - ensure_cache_dir(dest_dir) - specialize_shaders_to({'cell': sfile}, dest_dir) - ans = {} - for x in os.listdir(dest_dir): - if x.rpartition('.')[2] in ('spv', 'glsl', 'msl'): - with open(os.path.join(dest_dir, x), 'rb') as fb: - ans[x] = fb.read() - specialize_cache[cache_key] = (current, ans) - return ans - - def main() -> None: if not shutil.which(slangc[0]): raise SystemExit(f'The shader slang compiler ({slangc[0]}) not in PATH: {os.environ.get("PATH")}')