onedrop_hlsl/
texture_plan.rs

1//! Per-preset user-texture binding plan + the `tex2D` rewriter that
2//! consumes it.
3//!
4//! Built by the renderer at preset load time:
5//!
6//! 1. [`scan_user_samplers`] extracts `sampler sampler_X;` declarations from
7//!    the preset's HLSL.
8//! 2. The renderer resolves each name to a [`TextureSlot`] via its texture
9//!    pool. `sampler_rand0X` becomes a deterministic pick; literal names
10//!    become a pool lookup.
11//! 3. The slots are passed to [`crate::translate_shader_with_plan`] and the
12//!    wrapper so the emitted WGSL points at the right binding.
13
14use regex::Regex;
15use std::sync::LazyLock;
16
17/// Maximum simultaneously-bound user textures per preset. Sized to cover
18/// the in-the-wild distribution: a typical preset survey shows ≤ 4
19/// distinct `sampler sampler_X;` declarations per shader; 8 leaves
20/// headroom without blowing past the comp pipeline's bind-group budget.
21/// Presets that exceed this cap are translated with the first 8 slots
22/// used and the rest falling through to the standard fallback path
23/// (sampler_main).
24pub const MAX_USER_TEXTURE_SLOTS: usize = 8;
25
26/// WGSL binding name for user-texture slot `slot`. The codegen wrapper
27/// declares `var sampler_user_<n>_texture: texture_2d<f32>` for each
28/// slot `0..MAX_USER_TEXTURE_SLOTS`, and this is what the translator
29/// emits in `textureSample(...)` calls for plan-routed samplers.
30pub fn user_texture_binding_name(slot: usize) -> String {
31    format!("sampler_user_{slot}_texture")
32}
33
34/// One slot in a [`TextureBindingPlan`]. Carries the resolved pool
35/// texture name (or `None` when the renderer should fall back to the
36/// 1×1 white fallback) and the texture's `vec4<f32>(w, h, 1/w, 1/h)`
37/// for the `texsize_<NAME>` constant the wrapper emits.
38#[derive(Debug, Clone, PartialEq)]
39pub struct TextureSlot {
40    /// Logical pool name (lowercase filename stem) that the renderer
41    /// must bind into this slot. `None` → no texture available; bind
42    /// the fallback. Stable across reloads as long as the pool's disk
43    /// layout doesn't change.
44    pub pool_name: Option<String>,
45    /// MD2 `texsize_<NAME>` constant for the texture that's bound.
46    /// Emitted inline by the wrapper so user shader code can read the
47    /// dimensions without going through a uniform. `[1.0; 4]` for the
48    /// fallback case.
49    pub texsize: [f32; 4],
50}
51
52/// Per-preset mapping from HLSL sampler names to the comp pass's
53/// user-texture slots.
54///
55/// `aliases` carries the surface-level names the translator should
56/// treat as referencing each slot. A single slot may carry multiple
57/// aliases — `sampler sampler_clouds;` and `sampler sampler_fw_clouds;`
58/// both resolve to the same logical `clouds` texture but pick different
59/// filter samplers when sampled. The alias map maps a full sampler name
60/// (e.g. `"sampler_fw_clouds"`) to `(slot_index, sampler_kind)`.
61#[derive(Debug, Clone, Default)]
62pub struct TextureBindingPlan {
63    slots: Vec<TextureSlot>,
64    aliases: Vec<(String, usize, &'static str)>,
65}
66
67impl TextureBindingPlan {
68    /// Empty plan — `translate_shader_with_plan` behaves identically to
69    /// `translate_shader`. Used by callers that don't load disk
70    /// textures (CLI batch translate, tests) and as the default for
71    /// `translate_shader` itself.
72    pub fn empty() -> Self {
73        Self::default()
74    }
75
76    /// Number of filled slots. Equal to the comp pipeline's
77    /// user-texture bind count for this preset (the renderer fills the
78    /// remaining slots up to `MAX_USER_TEXTURE_SLOTS` with the fallback
79    /// texture).
80    pub fn slot_count(&self) -> usize {
81        self.slots.len()
82    }
83
84    /// Iterate slots in binding order. Index → slot.
85    pub fn slots(&self) -> &[TextureSlot] {
86        &self.slots
87    }
88
89    /// Register one logical texture (`pool_name = Some("clouds")` or
90    /// `None` for fallback) along with the set of HLSL surface aliases
91    /// that reference it. Returns the slot index, or `None` when the
92    /// cap was already hit. Subsequent calls with new aliases for an
93    /// existing `pool_name` re-use the same slot.
94    pub fn add_slot(
95        &mut self,
96        pool_name: Option<String>,
97        texsize: [f32; 4],
98        aliases: &[(String, &'static str)],
99    ) -> Option<usize> {
100        // Same `pool_name` shouldn't get a second slot — collapse onto
101        // the existing one.
102        let slot = if let Some(idx) = self
103            .slots
104            .iter()
105            .position(|s| s.pool_name == pool_name && pool_name.is_some())
106        {
107            idx
108        } else if self.slots.len() >= MAX_USER_TEXTURE_SLOTS {
109            log::warn!(
110                "user texture slot cap ({MAX_USER_TEXTURE_SLOTS}) reached; \
111                 alias {:?} routes through fallback",
112                aliases.iter().map(|(n, _)| n).collect::<Vec<_>>()
113            );
114            return None;
115        } else {
116            self.slots.push(TextureSlot { pool_name, texsize });
117            self.slots.len() - 1
118        };
119        for (alias, sampler_kind) in aliases {
120            // Skip duplicate aliases — keeps the first-write-wins
121            // semantics for the WGSL sampler binding (so the translator
122            // sees a stable routing if the renderer ever re-adds an
123            // alias).
124            if self.aliases.iter().any(|(a, _, _)| a == alias) {
125                continue;
126            }
127            self.aliases.push((alias.clone(), slot, sampler_kind));
128        }
129        Some(slot)
130    }
131
132    /// Map an HLSL `tex2D` sampler name to a `(slot_index, sampler_kind)`.
133    /// Returns `None` for built-in samplers (those are handled by
134    /// [`resolve_tex2d_sampler`] before the plan is consulted) and for
135    /// names the plan never registered an alias for.
136    pub fn lookup_tex2d(&self, sampler_name: &str) -> Option<(usize, &'static str)> {
137        self.aliases
138            .iter()
139            .find(|(a, _, _)| a == sampler_name)
140            .map(|(_, slot, kind)| (*slot, *kind))
141    }
142}
143
144/// Extract every `sampler sampler_X;` declaration from a MilkDrop comp
145/// shader HLSL and return the **logical name** (the part after
146/// `sampler_`) for each occurrence that isn't already a built-in.
147///
148/// Built-ins skipped:
149/// - `main` (and its `fw/fc/pw/pc_main` variants)
150/// - `noise_lq` / `noise_mq` / `noise_hq` (and the same set prefixed by
151///   `fw/fc/pw/pc_`)
152/// - `noisevol_lq` / `noisevol_hq` (same prefix set)
153/// - `blur1` / `blur2` / `blur3`
154///
155/// The returned list preserves declaration order with duplicates
156/// removed. The renderer uses this to know which textures it needs to
157/// bind for this preset.
158///
159/// **Format note** — `sampler sampler_X` and `sampler2D sampler_X` both
160/// match (MD2 uses both spellings; the type matters to HLSL but not to
161/// us since we strip the decl entirely before translation).
162pub fn scan_user_samplers(hlsl: &str) -> Vec<UserSamplerRef> {
163    static SAMPLER_DECL: LazyLock<Regex> = LazyLock::new(|| {
164        Regex::new(
165            r"(?m)^\s*(?:sampler|texture|texture2D|sampler2D)\s+(sampler_[A-Za-z_][A-Za-z0-9_]*)\s*;",
166        )
167        .unwrap()
168    });
169
170    let mut out: Vec<UserSamplerRef> = Vec::new();
171    for cap in SAMPLER_DECL.captures_iter(hlsl) {
172        let full_name = cap.get(1).unwrap().as_str();
173        if is_builtin_sampler_name(full_name) {
174            continue;
175        }
176        if out.iter().any(|s| s.full_name == full_name) {
177            continue;
178        }
179        let (logical, sampler_kind) = decompose_sampler_name(full_name);
180        out.push(UserSamplerRef {
181            full_name: full_name.to_string(),
182            logical_name: logical,
183            sampler_kind,
184        });
185    }
186    out
187}
188
189/// One parsed `sampler sampler_X;` declaration. The renderer consumes
190/// this to populate a [`TextureBindingPlan`].
191#[derive(Debug, Clone, PartialEq)]
192pub struct UserSamplerRef {
193    /// Full HLSL sampler name as written: `sampler_clouds`,
194    /// `sampler_fw_clouds`, `sampler_rand02_smalltiled`, …
195    pub full_name: String,
196    /// Logical texture name (filter prefix stripped):
197    /// `sampler_fw_clouds` → `"clouds"`, `sampler_rand02` → `"rand02"`,
198    /// `sampler_rand02_smalltiled` → `"rand02_smalltiled"`.
199    pub logical_name: String,
200    /// WGSL sampler binding the translator should pair with this
201    /// texture. `sampler_fw_*` → `"sampler_fw"`, plain `sampler_*` →
202    /// `"sampler_fw"` (MD2 default for user textures is linear+wrap).
203    pub sampler_kind: &'static str,
204}
205
206/// Decompose an HLSL user-sampler name into `(logical_name,
207/// sampler_kind)`. The logical name is what the renderer looks up in
208/// the texture pool; the sampler kind is the WGSL sampler binding used
209/// at the `textureSample` call site.
210pub(crate) fn decompose_sampler_name(full: &str) -> (String, &'static str) {
211    if let Some(rest) = full.strip_prefix("sampler_fw_") {
212        return (rest.to_string(), "sampler_fw");
213    }
214    if let Some(rest) = full.strip_prefix("sampler_fc_") {
215        return (rest.to_string(), "sampler_fc");
216    }
217    if let Some(rest) = full.strip_prefix("sampler_pw_") {
218        return (rest.to_string(), "sampler_pw");
219    }
220    if let Some(rest) = full.strip_prefix("sampler_pc_") {
221        return (rest.to_string(), "sampler_pc");
222    }
223    if let Some(rest) = full.strip_prefix("sampler_") {
224        // MD2's default sampler state for user textures is linear+wrap,
225        // matching what most authored shaders expect for tiled patterns
226        // (`clouds`, `worms`, `lichen`, …).
227        return (rest.to_string(), "sampler_fw");
228    }
229    (full.to_string(), "sampler_main")
230}
231
232/// `true` for sampler names the codegen wrapper already binds.
233/// Filtering these out keeps the user-texture plan focused on
234/// disk-loaded textures.
235fn is_builtin_sampler_name(full: &str) -> bool {
236    matches!(
237        full,
238        "sampler_main"
239            | "sampler_fw_main"
240            | "sampler_fc_main"
241            | "sampler_pw_main"
242            | "sampler_pc_main"
243            | "sampler_blur1"
244            | "sampler_blur2"
245            | "sampler_blur3"
246            | "sampler_noise_lq"
247            | "sampler_noise_mq"
248            | "sampler_noise_hq"
249            | "sampler_noisevol_lq"
250            | "sampler_noisevol_hq"
251    ) || matches!(
252        decompose_sampler_name(full).0.as_str(),
253        // Prefixed noise — `sampler_fw_noise_hq` etc. are routed by
254        // resolve_tex2d_sampler.
255        "noise_lq"
256            | "noise_mq"
257            | "noise_hq"
258            | "noisevol_lq"
259            | "noisevol_hq"
260            | "main"
261            | "blur1"
262            | "blur2"
263            | "blur3"
264    )
265}
266
267/// Resolve an MD2 sampler name to the `(texture_binding,
268/// sampler_binding, recognised)` triple the WGSL wrapper actually
269/// exposes. `recognised = true` suppresses the `/*was: …*/` debug
270/// comment for bindings we routed deliberately (vs. the fallback case).
271///
272/// 1. **Noise pack textures** — `sampler_noise_lq` / `_mq` / `_hq`
273///    route onto the dedicated noise textures using `sampler_pw`
274///    (point+wrap), matching MD2's high-frequency sampling intent.
275/// 2. **fw/fc/pw/pc variants of main** — `sampler_fw_main`,
276///    `_fc_main`, `_pw_main`, `_pc_main` keep `sampler_main_texture`
277///    but switch the sampler to the matching filter × address mode.
278/// 3. **Anything else** — falls back to `(sampler_main_texture,
279///    sampler_main)` so unknown / user-loaded sampler names still
280///    produce valid WGSL. The original name is kept in a trailing
281///    comment for debugging.
282pub(crate) fn resolve_tex2d_sampler(name: &str) -> (&'static str, &'static str, bool) {
283    match name {
284        "sampler_noise_lq"
285        | "sampler_pw_noise_lq"
286        | "sampler_fw_noise_lq"
287        | "sampler_pc_noise_lq"
288        | "sampler_fc_noise_lq" => ("sampler_noise_lq_texture", noise_sampler_for(name), true),
289        "sampler_noise_mq"
290        | "sampler_pw_noise_mq"
291        | "sampler_fw_noise_mq"
292        | "sampler_pc_noise_mq"
293        | "sampler_fc_noise_mq" => ("sampler_noise_mq_texture", noise_sampler_for(name), true),
294        "sampler_noise_hq"
295        | "sampler_pw_noise_hq"
296        | "sampler_fw_noise_hq"
297        | "sampler_pc_noise_hq"
298        | "sampler_fc_noise_hq" => ("sampler_noise_hq_texture", noise_sampler_for(name), true),
299        "sampler_fw_main" => ("sampler_main_texture", "sampler_fw", true),
300        "sampler_fc_main" => ("sampler_main_texture", "sampler_fc", true),
301        "sampler_pw_main" => ("sampler_main_texture", "sampler_pw", true),
302        "sampler_pc_main" => ("sampler_main_texture", "sampler_pc", true),
303        // Already-resolved or unknown — fall through to main+linear.
304        _ => ("sampler_main_texture", "sampler_main", false),
305    }
306}
307
308/// For a noise sampler name embedded in one of the 4 MD2 variants,
309/// pick the sampler binding. Plain `sampler_noise_lq` (no prefix)
310/// defaults to the point+wrap variant — that's how MD2 samples noise
311/// pixel-by-pixel when the author wants the raw value.
312pub(crate) fn noise_sampler_for(name: &str) -> &'static str {
313    if name.starts_with("sampler_fw_") {
314        "sampler_fw"
315    } else if name.starts_with("sampler_fc_") {
316        "sampler_fc"
317    } else if name.starts_with("sampler_pc_") {
318        "sampler_pc"
319    } else {
320        "sampler_pw"
321    }
322}
323
324/// Rewrite `tex2D(<sampler>, <uv>)` to a WGSL `textureSample` call
325/// against the `(texture, sampler)` pair resolved from the MD2 sampler
326/// name. Paren-balanced because the uv expression often contains nested
327/// calls (`tex2D(s, uv + offset(t))`).
328pub(crate) fn replace_texture_sampling_with_plan(code: &str, plan: &TextureBindingPlan) -> String {
329    let bytes = code.as_bytes();
330    let mut out = String::with_capacity(code.len());
331    let mut i = 0usize;
332
333    while i < bytes.len() {
334        if i + 6 <= bytes.len()
335            && &bytes[i..i + 5] == b"tex2D"
336            && bytes[i + 5] == b'('
337            && (i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_'))
338        {
339            let arg_start = i + 6;
340            let mut j = arg_start;
341            let mut depth = 1i32;
342            let mut comma = None;
343            while j < bytes.len() {
344                match bytes[j] {
345                    b'(' => depth += 1,
346                    b')' => {
347                        depth -= 1;
348                        if depth == 0 {
349                            break;
350                        }
351                    }
352                    b',' if depth == 1 && comma.is_none() => comma = Some(j),
353                    _ => {}
354                }
355                j += 1;
356            }
357            if let Some(c) = comma
358                && j < bytes.len()
359            {
360                let sampler = code[arg_start..c].trim();
361                let uv = code[c + 1..j].trim();
362                // Built-in routing wins first (main / fw_main / noise_*).
363                let (tex, smp, recognised) = resolve_tex2d_sampler(sampler);
364                if recognised {
365                    out.push_str(&format!("textureSample({tex}, {smp}, {uv})"));
366                } else if let Some((slot, sampler_kind)) = plan.lookup_tex2d(sampler) {
367                    // Plan-driven routing for user-loaded textures.
368                    let tex_name = user_texture_binding_name(slot);
369                    out.push_str(&format!("textureSample({tex_name}, {sampler_kind}, {uv})"));
370                } else {
371                    out.push_str(&format!(
372                        "textureSample({tex}, {smp}, {uv}) /*was: {sampler}*/"
373                    ));
374                }
375                i = j + 1;
376                continue;
377            }
378        }
379        out.push(bytes[i] as char);
380        i += 1;
381    }
382
383    out
384}