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}