Compare commits

..

1 Commits

Author SHA1 Message Date
Kovid Goyal
9664f35596 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.
2026-07-03 08:28:04 +05:30
3 changed files with 56 additions and 230 deletions

View File

@@ -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) {

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2026, Kovid Goyal <kovid at kovidgoyal.net>
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")}')

View File

@@ -2,7 +2,6 @@
# License: GPLv3 Copyright: 2026, Kovid Goyal <kovid at kovidgoyal.net>
import os
import shutil
import tempfile
from kitty.shaders.slang import EntryPoint, SlangFile, Stage, build_import_graph, parse_slang_text, topological_sort
@@ -160,85 +159,3 @@ void vsMain() {}
f.write('not a slang file\n')
graph3 = build_import_graph(tmpdir)
self.assertNotIn('ignored', graph3)
def test_specialize_cell_shader(self):
from kitty.constants import slangc
from kitty.options.types import Options, defaults
from kitty.shaders.slang import specialize_cell_shader
def make_opts(**kwargs):
d = defaults._asdict()
d.update(kwargs)
return Options(d)
# No action when opts are the same as defaults
self.assertEqual(specialize_cell_shader(opts=defaults), {})
self.assertEqual(specialize_cell_shader(opts=None), {})
# Explicitly constructed opts equal to defaults must also be a no-op
self.assertEqual(specialize_cell_shader(opts=make_opts()), {})
if not shutil.which(slangc[0]):
self.skipTest(f'slangc ({slangc[0]}) not found in PATH')
# The skip is placed here intentionally: the default-opts assertions above
# do not require slangc and should always run. Everything below invokes
# the compiler to produce real GLSL output.
# Helper to run specialize_cell_shader with an isolated cache directory.
# Returns (result_dict, create_cache_dir_callable).
def compile_with_new_cache(opts):
cache_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, cache_dir, True)
# Each call with the same callable returns the same directory so
# that the caching logic inside specialize_cell_shader can kick in.
# Using a fresh mkdtemp() per invocation guarantees that no cached
# result from a previous call can interfere with this one.
def create_cache_dir():
return cache_dir
result = specialize_cell_shader(create_cache_dir=create_cache_dir, opts=opts)
return result, create_cache_dir
# Changing the options must produce non-empty output with compiled shaders.
opts_legacy = make_opts(text_composition_strategy='legacy')
result_legacy, ccdir1 = compile_with_new_cache(opts_legacy)
self.assertNotEqual(result_legacy, {}, 'Expected non-empty result for non-default opts')
# Output must include GLSL files whose content is valid GLSL text.
glsl_items = {k: v.decode() for k, v in result_legacy.items() if k.endswith('.glsl')}
self.assertTrue(glsl_items, 'Expected at least one .glsl file in the result')
for name, glsl_text in glsl_items.items():
self.assertIn('#version', glsl_text, f'{name} should contain a #version directive')
# With TEXT_NEW_GAMMA=false the compiler eliminates the new-gamma code
# path, so foreground_contrast_new must NOT appear in the fragment shader.
frag_glsl = {k: v for k, v in glsl_items.items() if k.endswith('.frag.glsl')}
self.assertTrue(frag_glsl, 'Expected at least one fragment GLSL file')
for name, glsl_text in frag_glsl.items():
self.assertNotIn('foreground_contrast_new', glsl_text,
f'{name}: legacy strategy should not contain foreground_contrast_new')
# Calling with the same opts and the same cache dir must return the
# identical dict object (cached, no recompilation).
result_legacy_again = specialize_cell_shader(create_cache_dir=ccdir1, opts=opts_legacy)
self.assertIs(result_legacy, result_legacy_again,
'Second call with unchanged opts must return the cached result')
# Changing options a second time must produce a different result.
opts_fg_override = make_opts(text_fg_override_threshold=(0.5, '%'))
result_fg, ccdir2 = compile_with_new_cache(opts_fg_override)
self.assertNotEqual(result_fg, {}, 'Expected non-empty result for fg_override opts')
self.assertNotEqual(result_legacy, result_fg,
'Different opts must produce different compiled output')
# Verify that the GLSL content actually differs between the two option sets.
frag_glsl2 = {k: v.decode() for k, v in result_fg.items() if k.endswith('.frag.glsl')}
for name in frag_glsl:
if name in frag_glsl2:
self.assertNotEqual(frag_glsl[name], frag_glsl2[name],
f'{name}: GLSL content must differ between option sets')
# Calling again with the second option set must also return the cached dict.
result_fg_again = specialize_cell_shader(create_cache_dir=ccdir2, opts=opts_fg_override)
self.assertIs(result_fg, result_fg_again,
'Second call with unchanged fg_override opts must return the cached result')