1#![forbid(unsafe_code)]
5
6use atrinik_render_api::{
7 Error as RenderError, FrameOutput, RecoveryMetrics, RenderRequest, Renderer, TargetDescriptor,
8 TargetKind, TargetLimits,
9};
10use atrinik_render_resources::{
11 DEFAULT_MAXIMUM_RESOURCE_BYTES, Error as ResourceError, ResourceBlob, ResourceProvider,
12 ResourceRequest, Rgba8Image, add_working_set_bytes, for_each_sprite_pixel, sample_rgba8,
13 shade_rgba8,
14};
15use atrinik_scene::{
16 Digest256, Layer, ResourceId, Sampling, SceneBundle, SceneLimits, SemanticId, Sprite, Viewport,
17};
18use sha2::{Digest, Sha256};
19use std::{
20 collections::{BTreeMap, HashMap, VecDeque},
21 sync::{Arc, RwLock},
22};
23
24#[derive(Default)]
25pub struct FakeResourceProvider {
26 blobs: RwLock<BTreeMap<(ResourceId, u64), Vec<u8>>>,
27}
28
29impl FakeResourceProvider {
30 pub fn insert(
35 &self,
36 id: ResourceId,
37 revision: u64,
38 bytes: Vec<u8>,
39 ) -> Result<(), ResourceError> {
40 self.blobs
41 .write()
42 .map_err(|_| ResourceError::Unavailable)?
43 .insert((id, revision), bytes);
44 Ok(())
45 }
46}
47
48impl ResourceProvider for FakeResourceProvider {
49 fn load(&self, request: ResourceRequest) -> Result<ResourceBlob, ResourceError> {
50 request.validate()?;
51 let bytes = self
52 .blobs
53 .read()
54 .map_err(|_| ResourceError::Unavailable)?
55 .get(&(request.id, request.revision))
56 .cloned()
57 .ok_or(ResourceError::NotFound)?;
58 ResourceBlob::for_request(request, bytes)
59 }
60}
61
62pub struct ReferenceRenderer {
65 target: TargetDescriptor,
66}
67
68pub struct FakeRenderer {
70 target: TargetDescriptor,
71 responses: VecDeque<Result<FrameOutput, RenderError>>,
72 rendered_revisions: Vec<u64>,
73}
74
75impl FakeRenderer {
76 pub fn new(target: TargetDescriptor) -> Result<Self, RenderError> {
81 Ok(Self {
82 target: target.validate(TargetLimits::default())?,
83 responses: VecDeque::new(),
84 rendered_revisions: Vec::new(),
85 })
86 }
87
88 pub fn push_response(&mut self, response: Result<FrameOutput, RenderError>) {
89 self.responses.push_back(response);
90 }
91
92 #[must_use]
93 pub fn rendered_revisions(&self) -> &[u64] {
94 &self.rendered_revisions
95 }
96}
97
98impl Renderer for FakeRenderer {
99 fn target(&self) -> TargetDescriptor {
100 self.target
101 }
102
103 fn resize(&mut self, width: u32, height: u32) -> Result<(), RenderError> {
104 self.target = TargetDescriptor {
105 width,
106 height,
107 ..self.target
108 }
109 .validate(TargetLimits::default())?;
110 Ok(())
111 }
112
113 fn render(&mut self, request: RenderRequest<'_>) -> Result<FrameOutput, RenderError> {
114 self.rendered_revisions.push(request.scene.revision());
115 self.responses.pop_front().unwrap_or_else(|| {
116 Err(RenderError::Internal(
117 "fake response queue is empty".to_owned(),
118 ))
119 })
120 }
121}
122
123impl ReferenceRenderer {
124 pub fn new(target: TargetDescriptor) -> Result<Self, RenderError> {
129 Ok(Self {
130 target: target.validate(TargetLimits::default())?,
131 })
132 }
133}
134
135impl Renderer for ReferenceRenderer {
136 fn target(&self) -> TargetDescriptor {
137 self.target
138 }
139
140 fn resize(&mut self, width: u32, height: u32) -> Result<(), RenderError> {
141 self.target = TargetDescriptor {
142 width,
143 height,
144 ..self.target
145 }
146 .validate(TargetLimits::default())?;
147 Ok(())
148 }
149
150 fn render(&mut self, request: RenderRequest<'_>) -> Result<FrameOutput, RenderError> {
151 if request.scene.viewport().width != self.target.width
152 || request.scene.viewport().height != self.target.height
153 {
154 return Err(RenderError::InvalidTarget);
155 }
156 let pixel_count = pixel_count(self.target.width, self.target.height)?;
157 let clear = color_to_rgba8(request.scene.clear_color());
158 let mut output = FrameOutput {
159 width: self.target.width,
160 height: self.target.height,
161 rgba8: clear.repeat(pixel_count),
162 semantic_ids: vec![0; pixel_count],
163 depth: vec![i16::MIN; pixel_count],
164 coverage: vec![0; pixel_count],
165 metrics: RecoveryMetrics::default(),
166 };
167 let mut uploaded_bytes = 0_u64;
168 let mut resource_requests = 0_u64;
169 let mut images: HashMap<(ResourceId, u64, Digest256), Arc<Rgba8Image>> = HashMap::new();
170 for sprite in request.scene.sprites() {
171 let key = (
172 sprite.resource_id,
173 sprite.resource_revision,
174 sprite.resource_digest,
175 );
176 let image = if let Some(image) = images.get(&key) {
177 image.clone()
178 } else {
179 let blob = request
180 .resources
181 .load(ResourceRequest {
182 id: sprite.resource_id,
183 revision: sprite.resource_revision,
184 digest: sprite.resource_digest,
185 maximum_bytes: DEFAULT_MAXIMUM_RESOURCE_BYTES,
186 })
187 .map_err(|error| RenderError::Resource(error.to_string()))?;
188 let image = Arc::new(
189 Rgba8Image::decode(&blob, 8_192, 16_777_216)
190 .map_err(|error| RenderError::Resource(error.to_string()))?,
191 );
192 uploaded_bytes = add_working_set_bytes(uploaded_bytes, image.pixels().len() as u64)
193 .map_err(|_| RenderError::LimitExceeded)?;
194 resource_requests += 1;
195 images.insert(key, image.clone());
196 image
197 };
198 raster_sprite(&mut output, sprite, &image, request.scene.clock_millis());
199 }
200 output.metrics.frames_submitted = 1;
201 output.metrics.resource_requests = resource_requests;
202 output.metrics.uploaded_bytes = uploaded_bytes;
203 output.metrics.vertex_count = (request.scene.sprites().len() as u64) * 6;
204 output.metrics.vertex_allocation_bytes = output.metrics.vertex_count * 40;
205 output.metrics.target_allocation_bytes = (pixel_count as u64) * 11;
206 output.validate()?;
207 Ok(output)
208 }
209}
210
211fn pixel_count(width: u32, height: u32) -> Result<usize, RenderError> {
212 usize::try_from(width)
213 .ok()
214 .and_then(|width| {
215 usize::try_from(height)
216 .ok()
217 .and_then(|height| width.checked_mul(height))
218 })
219 .ok_or(RenderError::LimitExceeded)
220}
221
222fn raster_sprite(output: &mut FrameOutput, sprite: &Sprite, image: &Rgba8Image, clock_millis: u64) {
223 for_each_sprite_pixel(
224 sprite,
225 output.width,
226 output.height,
227 |_x, _y, index, u, v| {
228 let source = shade_rgba8(
229 sample_rgba8(image, u, v, sprite.sampling),
230 sprite.color,
231 sprite.layer,
232 clock_millis,
233 );
234 if source[3] != 0 {
235 blend(&mut output.rgba8[index * 4..index * 4 + 4], source);
236 output.semantic_ids[index] = sprite.semantic_id.0;
237 output.depth[index] = sprite.depth;
238 output.coverage[index] = source[3];
239 }
240 },
241 );
242}
243
244#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
245fn color_to_rgba8(color: [f32; 4]) -> [u8; 4] {
246 color.map(|channel| (channel.mul_add(255.0, 0.5)).floor() as u8)
247}
248
249fn blend(destination: &mut [u8], source: [u8; 4]) {
250 let alpha = u32::from(source[3]);
251 let inverse = 255 - alpha;
252 for channel in 0..3 {
253 destination[channel] = u8::try_from(
254 (u32::from(source[channel]) * alpha + u32::from(destination[channel]) * inverse + 127)
255 / 255,
256 )
257 .expect("8-bit blend channel remains in range");
258 }
259 destination[3] = u8::try_from(alpha + (u32::from(destination[3]) * inverse + 127) / 255)
260 .expect("8-bit alpha remains in range");
261}
262
263#[must_use]
264pub fn synthetic_digest() -> Digest256 {
265 Digest256(Sha256::digest(synthetic_resource()).into())
266}
267
268#[must_use]
269pub fn synthetic_resource() -> &'static [u8] {
270 b"ATX1\x02\x00\x00\x00\x02\x00\x00\x00\xff\xff\xff\xff\xff\x40\x20\xff\x20\xff\x80\xc0\x20\x60\xff\x40"
271}
272
273pub fn synthetic_provider() -> Result<Arc<FakeResourceProvider>, ResourceError> {
278 let provider = Arc::new(FakeResourceProvider::default());
279 provider.insert(ResourceId(1), 1, synthetic_resource().to_vec())?;
280 Ok(provider)
281}
282
283#[must_use = "synthetic scene construction can fail"]
288#[allow(clippy::cast_precision_loss)]
289pub fn synthetic_scene(width: u32, height: u32) -> Result<SceneBundle, atrinik_scene::Error> {
290 SceneBundle::new(
291 1,
292 375,
293 Viewport {
294 width,
295 height,
296 scale_milli: 1_000,
297 },
298 [0.02, 0.03, 0.05, 1.0],
299 [
300 Sprite {
301 semantic_id: SemanticId(1),
302 resource_id: ResourceId(1),
303 resource_revision: 1,
304 resource_digest: synthetic_digest(),
305 x: 1.0,
306 y: 1.0,
307 width: (width / 2).max(1) as f32,
308 height: (height / 2).max(1) as f32,
309 depth: 0,
310 painter_order: 0,
311 color: [0.85, 0.12, 0.18, 1.0],
312 sampling: Sampling::Nearest,
313 layer: Layer::World,
314 },
315 Sprite {
316 semantic_id: SemanticId(2),
317 resource_id: ResourceId(1),
318 resource_revision: 1,
319 resource_digest: synthetic_digest(),
320 x: (width / 3) as f32,
321 y: (height / 3) as f32,
322 width: (width / 2).max(1) as f32,
323 height: (height / 2).max(1) as f32,
324 depth: 1,
325 painter_order: 1,
326 color: [0.1, 0.55, 0.95, 0.75],
327 sampling: Sampling::Linear,
328 layer: Layer::Effect,
329 },
330 Sprite {
331 semantic_id: SemanticId(3),
332 resource_id: ResourceId(1),
333 resource_revision: 1,
334 resource_digest: synthetic_digest(),
335 x: -1.25,
336 y: height.saturating_sub(3) as f32,
337 width: width.saturating_add(2) as f32,
338 height: 2.5,
339 depth: 0,
340 painter_order: 0,
341 color: [0.95, 0.95, 0.75, 0.8],
342 sampling: Sampling::Nearest,
343 layer: Layer::Interface,
344 },
345 ],
346 SceneLimits::default(),
347 )
348}
349
350#[allow(clippy::cast_precision_loss)]
356pub fn dense_scene(width: u32, height: u32) -> Result<SceneBundle, atrinik_scene::Error> {
357 let mut sprites = Vec::with_capacity(1_024);
358 for index in 0..1_024_u32 {
359 let column = index % 32;
360 let row = index / 32;
361 let depth = i16::try_from(index % 17).map_err(|_| atrinik_scene::Error::InvalidValue)?;
362 sprites.push(Sprite {
363 semantic_id: SemanticId(index + 1),
364 resource_id: ResourceId(1),
365 resource_revision: 1,
366 resource_digest: synthetic_digest(),
367 x: (column * 4) as f32,
368 y: (row * 4) as f32,
369 width: 4.0,
370 height: 4.0,
371 depth,
372 painter_order: index,
373 color: [0.55, 0.7, 0.9, 0.9],
374 sampling: if index % 2 == 0 {
375 Sampling::Nearest
376 } else {
377 Sampling::Linear
378 },
379 layer: if index % 11 == 0 {
380 Layer::Effect
381 } else {
382 Layer::World
383 },
384 });
385 }
386 SceneBundle::new(
387 2,
388 375,
389 Viewport {
390 width,
391 height,
392 scale_milli: 1_000,
393 },
394 [0.01, 0.01, 0.02, 1.0],
395 sprites,
396 SceneLimits::default(),
397 )
398}
399
400pub fn structural_scene(width: u32, height: u32) -> Result<SceneBundle, atrinik_scene::Error> {
405 if width < 32 || height < 32 {
406 return Err(atrinik_scene::Error::InvalidValue);
407 }
408 let digest = synthetic_digest();
409 SceneBundle::new(
410 3,
411 375,
412 Viewport {
413 width,
414 height,
415 scale_milli: 1_500,
416 },
417 [0.04, 0.05, 0.08, 1.0],
418 [
419 fixture_sprite(1, [3.0, 20.0, 26.0, 5.0], -2, 0, Layer::World, digest),
420 fixture_sprite(2, [8.0, 7.0, 5.0, 20.0], -1, 1, Layer::World, digest),
421 fixture_sprite(3, [13.0, 11.0, 5.0, 16.0], 0, 2, Layer::World, digest),
422 fixture_sprite(4, [18.0, 15.0, 5.0, 12.0], 1, 3, Layer::World, digest),
423 fixture_sprite(5, [23.0, 19.0, 5.0, 8.0], 2, 4, Layer::World, digest),
424 Sprite {
425 color: [0.35, 0.55, 0.8, 0.28],
426 sampling: Sampling::Linear,
427 ..fixture_sprite(6, [-2.0, 9.5, 36.0, 14.0], 3, 0, Layer::Effect, digest)
428 },
429 Sprite {
430 color: [1.0, 0.92, 0.55, 0.65],
431 ..fixture_sprite(7, [10.0, 4.0, 12.0, 6.0], 4, 1, Layer::Effect, digest)
432 },
433 Sprite {
434 color: [0.12, 0.15, 0.2, 0.92],
435 ..fixture_sprite(8, [0.0, 27.0, 32.0, 5.0], 0, 0, Layer::Interface, digest)
436 },
437 Sprite {
438 color: [0.95, 0.95, 0.9, 1.0],
439 ..fixture_sprite(9, [35.0, 26.0, 2.0, 6.0], 1, 1, Layer::Interface, digest)
440 },
441 Sprite {
442 color: [0.95, 0.95, 0.9, 1.0],
443 ..fixture_sprite(10, [41.0, 26.0, 2.0, 6.0], 1, 2, Layer::Interface, digest)
444 },
445 Sprite {
446 color: [0.95, 0.95, 0.9, 1.0],
447 ..fixture_sprite(11, [35.0, 28.0, 8.0, 2.0], 1, 3, Layer::Interface, digest)
448 },
449 ],
450 SceneLimits::default(),
451 )
452}
453
454fn fixture_sprite(
455 semantic_id: u32,
456 bounds: [f32; 4],
457 depth: i16,
458 painter_order: u32,
459 layer: Layer,
460 digest: Digest256,
461) -> Sprite {
462 Sprite {
463 semantic_id: SemanticId(semantic_id),
464 resource_id: ResourceId(1),
465 resource_revision: 1,
466 resource_digest: digest,
467 x: bounds[0],
468 y: bounds[1],
469 width: bounds[2],
470 height: bounds[3],
471 depth,
472 painter_order,
473 color: [0.85, 0.75, 0.6, 1.0],
474 sampling: Sampling::Nearest,
475 layer,
476 }
477}
478
479#[must_use]
480pub const fn offscreen_target(width: u32, height: u32) -> TargetDescriptor {
481 TargetDescriptor {
482 kind: TargetKind::Offscreen,
483 width,
484 height,
485 backend: atrinik_render_api::BackendPreference::Automatic,
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::{
492 FakeRenderer, FakeResourceProvider, ReferenceRenderer, offscreen_target,
493 synthetic_provider, synthetic_scene,
494 };
495 use atrinik_render_api::{Error, RenderRequest, Renderer};
496 use atrinik_render_resources::{
497 Error as ResourceError, ResourceBlob, ResourceProvider, ResourceRequest,
498 };
499 use atrinik_scene::{Digest256, ResourceId, SceneBundle, SceneLimits};
500 use sha2::{Digest, Sha256};
501 use std::sync::Arc;
502
503 struct FailingProvider(ResourceError);
504
505 impl ResourceProvider for FailingProvider {
506 fn load(&self, _request: ResourceRequest) -> Result<ResourceBlob, ResourceError> {
507 Err(self.0)
508 }
509 }
510
511 #[test]
512 fn reference_output_is_exact_and_repeatable() {
513 let scene = synthetic_scene(16, 16).unwrap();
514 let request = RenderRequest {
515 scene: &scene,
516 resources: synthetic_provider().unwrap(),
517 };
518 let first = ReferenceRenderer::new(offscreen_target(16, 16))
519 .unwrap()
520 .render(request.clone())
521 .unwrap();
522 let second = ReferenceRenderer::new(offscreen_target(16, 16))
523 .unwrap()
524 .render(request)
525 .unwrap();
526 assert_eq!(first, second);
527 assert!(first.semantic_ids.contains(&1));
528 assert!(first.semantic_ids.contains(&2));
529 }
530
531 #[test]
532 fn missing_and_digest_mismatched_resources_fail_closed() {
533 let scene = synthetic_scene(16, 16).unwrap();
534 let missing = ReferenceRenderer::new(offscreen_target(16, 16))
535 .unwrap()
536 .render(RenderRequest {
537 scene: &scene,
538 resources: Arc::new(FakeResourceProvider::default()),
539 })
540 .unwrap_err();
541 assert!(matches!(missing, Error::Resource(message) if message.contains("NotFound")));
542
543 let provider = Arc::new(FakeResourceProvider::default());
544 provider
545 .insert(ResourceId(1), 1, b"wrong bytes".to_vec())
546 .unwrap();
547 let mismatch = ReferenceRenderer::new(offscreen_target(16, 16))
548 .unwrap()
549 .render(RenderRequest {
550 scene: &scene,
551 resources: provider,
552 })
553 .unwrap_err();
554 assert!(matches!(mismatch, Error::Resource(message) if message.contains("DigestMismatch")));
555
556 let corrupt_bytes = b"not-an-atx1-texture".to_vec();
557 let digest = Digest256(Sha256::digest(&corrupt_bytes).into());
558 let mut sprites = scene.sprites().to_vec();
559 for sprite in &mut sprites {
560 sprite.resource_digest = digest;
561 }
562 let corrupt_scene = SceneBundle::new(
563 scene.revision(),
564 scene.clock_millis(),
565 scene.viewport(),
566 scene.clear_color(),
567 sprites,
568 SceneLimits::default(),
569 )
570 .unwrap();
571 let corrupt_provider = Arc::new(FakeResourceProvider::default());
572 corrupt_provider
573 .insert(ResourceId(1), 1, corrupt_bytes)
574 .unwrap();
575 let corrupt = ReferenceRenderer::new(offscreen_target(16, 16))
576 .unwrap()
577 .render(RenderRequest {
578 scene: &corrupt_scene,
579 resources: corrupt_provider,
580 })
581 .unwrap_err();
582 assert!(matches!(corrupt, Error::Resource(message) if message.contains("InvalidFormat")));
583
584 let oversized = ReferenceRenderer::new(offscreen_target(16, 16))
585 .unwrap()
586 .render(RenderRequest {
587 scene: &scene,
588 resources: Arc::new(FailingProvider(ResourceError::LimitExceeded)),
589 })
590 .unwrap_err();
591 assert!(matches!(oversized, Error::Resource(message) if message.contains("LimitExceeded")));
592 }
593
594 #[test]
595 fn programmable_fake_records_requests_and_failures() {
596 let scene = synthetic_scene(4, 4).unwrap();
597 let request = RenderRequest {
598 scene: &scene,
599 resources: synthetic_provider().unwrap(),
600 };
601 let recovered = ReferenceRenderer::new(offscreen_target(4, 4))
602 .unwrap()
603 .render(request.clone())
604 .unwrap();
605 let mut fake = FakeRenderer::new(offscreen_target(4, 4)).unwrap();
606 fake.push_response(Err(Error::SurfaceUnavailable));
607 fake.push_response(Err(Error::SurfaceLost));
608 fake.push_response(Err(Error::DeviceLost));
609 fake.push_response(Ok(recovered.clone()));
610 assert_eq!(fake.render(request.clone()), Err(Error::SurfaceUnavailable));
611 assert_eq!(fake.render(request.clone()), Err(Error::SurfaceLost));
612 assert_eq!(fake.render(request.clone()), Err(Error::DeviceLost));
613 assert_eq!(fake.render(request), Ok(recovered));
614 assert_eq!(fake.rendered_revisions(), [1, 1, 1, 1]);
615 }
616}