1use std::f64::consts::{PI, TAU};
21
22use crate::behavior::Behavior;
23use crate::coat::{KoiBody, KoiCoat, WHITE_RAMP};
24use crate::koi::{
25 ADULT_CONTRIBUTIONS, KoiDevelopment, YOUNG_SHAPE_CONTRIBUTIONS, calculate_growth_fraction,
26};
27use crate::motion::{Course, STILL_POSE, SwimPose, calculate_swim_pose, frame_count};
28
29pub const FRAME_WIDTH_PIXELS: u32 = 256;
31pub const FRAME_HEIGHT_PIXELS: u32 = FRAME_WIDTH_PIXELS;
33
34#[derive(Debug, Eq, PartialEq)]
36pub struct KoiFrame {
37 rgba_pixels: Vec<[u8; 4]>,
38}
39
40impl KoiFrame {
41 pub fn as_rgba_bytes(&self) -> &[u8] {
44 self.rgba_pixels.as_flattened()
45 }
46
47 #[cfg(test)]
49 pub(crate) fn from_rgba_pixels(rgba_pixels: Vec<[u8; 4]>) -> Self {
50 Self { rgba_pixels }
51 }
52}
53
54pub fn render_koi(koi_development: &KoiDevelopment, koi_body: KoiBody) -> KoiFrame {
61 let koi_anatomy = calculate_anatomy(koi_development.total_contributions);
62 render_pose(
63 koi_development,
64 koi_body,
65 &koi_anatomy,
66 &STILL_POSE,
67 Framing::CenterOnKoi,
68 )
69}
70
71pub fn render_swim_frame(
80 koi_development: &KoiDevelopment,
81 koi_body: KoiBody,
82 behavior: Behavior,
83 frame_index: u32,
84) -> KoiFrame {
85 let koi_anatomy = calculate_anatomy(koi_development.total_contributions);
86 let swim_pose = calculate_swim_pose(behavior, frame_index);
87 let pose_center = match swim_pose.course {
88 Course::Lap { .. } => [0.0, 0.0],
89 Course::Hover { drift_pixels, .. } => {
90 let hover_fit = fit_hover_loop(behavior, &koi_anatomy);
91 [0, 1].map(|axis_index| {
92 hover_fit.sweep_center[axis_index]
93 - hover_fit.drift_scales[axis_index] * drift_pixels[axis_index]
94 })
95 }
96 };
97 render_pose(
98 koi_development,
99 koi_body,
100 &koi_anatomy,
101 &swim_pose,
102 Framing::At(pose_center),
103 )
104}
105
106enum Framing {
109 CenterOnKoi,
110 At([f64; 2]),
111}
112
113struct HoverFit {
115 sweep_center: [f64; 2],
116 drift_scales: [f64; 2],
118}
119
120fn fit_hover_loop(behavior: Behavior, koi_anatomy: &KoiAnatomy) -> HoverFit {
123 const FIT_MARGIN_PIXELS: f64 = 2.5;
125 let frame_bounds: Vec<([[f64; 2]; 2], [f64; 2])> = (0..frame_count(behavior))
126 .map(|frame_index| {
127 let swim_pose = calculate_swim_pose(behavior, frame_index);
128 let body_spine = build_spine(koi_anatomy, &swim_pose);
129 let fin_curves = build_fins(&body_spine, koi_anatomy, &swim_pose);
130 let [mut min_corner, mut max_corner] = measure_curve_bounds(&body_spine, &fin_curves);
131 for whisker_point in
133 calculate_whisker_points(&body_spine, koi_anatomy, swim_pose.whisker_sway_radians)
134 {
135 for axis_index in 0..2 {
136 min_corner[axis_index] = min_corner[axis_index].min(whisker_point[axis_index]);
137 max_corner[axis_index] =
138 max_corner[axis_index].max(whisker_point[axis_index] + 1.0);
139 }
140 }
141 let drift_pixels = match swim_pose.course {
142 Course::Hover { drift_pixels, .. } => drift_pixels,
143 Course::Lap { .. } => [0.0; 2],
144 };
145 ([min_corner, max_corner], drift_pixels)
146 })
147 .collect();
148 let frame_sizes = [
149 f64::from(FRAME_WIDTH_PIXELS),
150 f64::from(FRAME_HEIGHT_PIXELS),
151 ];
152 let mut hover_fit = HoverFit {
153 sweep_center: [0.0; 2],
154 drift_scales: [1.0; 2],
155 };
156 for axis_index in 0..2 {
157 let sweep_min = frame_bounds
158 .iter()
159 .map(|(bounds, _)| bounds[0][axis_index])
160 .fold(f64::INFINITY, f64::min);
161 let sweep_max = frame_bounds
162 .iter()
163 .map(|(bounds, _)| bounds[1][axis_index])
164 .fold(f64::NEG_INFINITY, f64::max);
165 let sweep_center = (sweep_min + sweep_max) / 2.0;
166 let frame_half = frame_sizes[axis_index] / 2.0;
167 let mut drift_scale: f64 = 1.0;
168 for (bounds, drift_pixels) in &frame_bounds {
169 let drift = drift_pixels[axis_index];
170 let room = if drift > 0.0 {
171 frame_half - FIT_MARGIN_PIXELS - (bounds[1][axis_index] - sweep_center)
172 } else {
173 frame_half - FIT_MARGIN_PIXELS + (bounds[0][axis_index] - sweep_center)
174 };
175 if drift != 0.0 {
176 drift_scale = drift_scale.min(room / drift.abs());
177 }
178 }
179 hover_fit.sweep_center[axis_index] = sweep_center;
180 hover_fit.drift_scales[axis_index] = drift_scale.clamp(0.0, 1.0);
181 }
182 hover_fit
183}
184
185fn render_pose(
186 koi_development: &KoiDevelopment,
187 koi_body: KoiBody,
188 koi_anatomy: &KoiAnatomy,
189 swim_pose: &SwimPose,
190 framing: Framing,
191) -> KoiFrame {
192 let koi_coat = KoiCoat::new(koi_development, koi_body, |along_fraction| {
193 calculate_body_half_width(along_fraction, koi_anatomy)
194 });
195 let body_spine = build_spine(koi_anatomy, swim_pose);
196 let fin_curves = build_fins(&body_spine, koi_anatomy, swim_pose);
197 let [min_corner, max_corner] = measure_curve_bounds(&body_spine, &fin_curves);
198 let pose_center = match framing {
199 Framing::CenterOnKoi => [
200 (min_corner[0] + max_corner[0]) / 2.0,
201 (min_corner[1] + max_corner[1]) / 2.0,
202 ],
203 Framing::At(pose_center) => pose_center,
204 };
205 let koi_appearance = KoiAppearance {
206 koi_anatomy,
207 koi_coat: &koi_coat,
208 };
209 let mut rgba_pixels = Vec::with_capacity((FRAME_WIDTH_PIXELS * FRAME_HEIGHT_PIXELS) as usize);
210 for pixel_y in 0..FRAME_HEIGHT_PIXELS {
211 for pixel_x in 0..FRAME_WIDTH_PIXELS {
212 let sample_position = [
213 f64::from(pixel_x) + 0.5 - f64::from(FRAME_WIDTH_PIXELS) / 2.0 + pose_center[0],
214 f64::from(pixel_y) + 0.5 - f64::from(FRAME_HEIGHT_PIXELS) / 2.0 + pose_center[1],
215 ];
216 let is_inside_bounds = (0..2).all(|axis_index| {
218 (min_corner[axis_index]..=max_corner[axis_index])
219 .contains(&sample_position[axis_index])
220 });
221 rgba_pixels.push(if is_inside_bounds {
222 sample_koi(
223 &body_spine,
224 &fin_curves,
225 sample_position,
226 [pixel_x, pixel_y],
227 &koi_appearance,
228 )
229 } else {
230 [0; 4]
231 });
232 }
233 }
234 paint_mouth(
235 &mut rgba_pixels,
236 &body_spine,
237 koi_body,
238 pose_center,
239 swim_pose.mouth_opening,
240 );
241 let fish_pixel_mask: Vec<bool> = rgba_pixels
242 .iter()
243 .map(|pixel_channels| pixel_channels[3] != 0)
244 .collect();
245 let whisker_points =
246 calculate_whisker_points(&body_spine, koi_anatomy, swim_pose.whisker_sway_radians);
247 paint_whiskers(&mut rgba_pixels, &whisker_points, pose_center);
248 paint_outline(&mut rgba_pixels);
249 if let Some(feeding_phase) = swim_pose.feeding_phase {
250 paint_feeding(
251 &mut rgba_pixels,
252 &body_spine,
253 koi_anatomy,
254 pose_center,
255 feeding_phase,
256 );
257 }
258 if swim_pose.depth > 0.0 {
259 paint_depth(&mut rgba_pixels, &fish_pixel_mask, swim_pose.depth);
260 }
261 KoiFrame { rgba_pixels }
262}
263
264fn calculate_mouth_position(body_spine: &[CurvePoint]) -> [f64; 2] {
266 const MOUTH_FORWARD_FRACTION: f64 = 0.75;
267 let snout = body_spine[0];
268 let forward_direction = calculate_forward_direction(body_spine, 0);
269 [0, 1].map(|axis_index| {
270 snout.position[axis_index]
271 + forward_direction[axis_index] * snout.half_width_pixels * MOUTH_FORWARD_FRACTION
272 })
273}
274
275fn paint_mouth(
280 rgba_pixels: &mut [[u8; 4]],
281 body_spine: &[CurvePoint],
282 koi_body: KoiBody,
283 pose_center: [f64; 2],
284 mouth_opening: f64,
285) {
286 const VISIBLE_OPENING: f64 = 0.1;
287 const CLOSED_RADIUS_PIXELS: f64 = 0.8;
288 const OPEN_RADIUS_PER_SNOUT_RADIUS: f64 = 0.5;
289 const MIN_OPEN_RADIUS_PIXELS: f64 = 1.8;
290 const RIM_WIDTH_PIXELS: f64 = 0.9;
291 if mouth_opening <= VISIBLE_OPENING {
292 return;
293 }
294 let open_radius_pixels = (body_spine[0].half_width_pixels * OPEN_RADIUS_PER_SNOUT_RADIUS)
295 .max(MIN_OPEN_RADIUS_PIXELS);
296 let radius_pixels = interpolate(
297 CLOSED_RADIUS_PIXELS,
298 open_radius_pixels,
299 mouth_opening.min(1.0),
300 );
301 let mouth_world = calculate_mouth_position(body_spine);
302 let frame_sizes = [
303 f64::from(FRAME_WIDTH_PIXELS),
304 f64::from(FRAME_HEIGHT_PIXELS),
305 ];
306 let mouth = [0, 1].map(|axis_index| {
307 mouth_world[axis_index] - pose_center[axis_index] + frame_sizes[axis_index] / 2.0
308 });
309 let rim_color = koi_body.select_mouth_rim_color();
310 let frame_width = FRAME_WIDTH_PIXELS as usize;
311 let first_y = (mouth[1] - radius_pixels).floor().max(0.0) as usize;
312 let last_y = ((mouth[1] + radius_pixels).ceil() as usize).min(FRAME_HEIGHT_PIXELS as usize - 1);
313 let first_x = (mouth[0] - radius_pixels).floor().max(0.0) as usize;
314 let last_x = ((mouth[0] + radius_pixels).ceil() as usize).min(frame_width - 1);
315 for pixel_y in first_y..=last_y {
316 for pixel_x in first_x..=last_x {
317 let distance_pixels =
318 (pixel_x as f64 + 0.5 - mouth[0]).hypot(pixel_y as f64 + 0.5 - mouth[1]);
319 if distance_pixels <= radius_pixels {
320 rgba_pixels[pixel_y * frame_width + pixel_x] =
321 if distance_pixels > radius_pixels - RIM_WIDTH_PIXELS {
322 rim_color
323 } else {
324 OUTLINE_COLOR
325 };
326 }
327 }
328 }
329}
330
331fn paint_feeding(
335 rgba_pixels: &mut [[u8; 4]],
336 body_spine: &[CurvePoint],
337 koi_anatomy: &KoiAnatomy,
338 pose_center: [f64; 2],
339 feeding_phase: f64,
340) {
341 const RING_COUNT: usize = 3;
342 const RING_WIDTH_PER_BODY_LENGTH: f64 = 0.3;
343 const RING_HEIGHT_PER_WIDTH: f64 = 0.4;
344 const RING_ANGLE_STEP_RADIANS: f64 = 0.035;
345 const PELLET_COUNT: usize = 5;
346 const PELLET_NEAREST_PIXELS: f64 = 4.0;
347 const PELLET_REACH_PIXELS: f64 = 14.0;
348 const PELLET_WANDER_PIXELS: f64 = 9.0;
349 const PELLET_EATEN_LIFE: f64 = 0.92;
351 const PELLET_COLORS: [[u8; 4]; 2] = [[228, 166, 114, 255], [184, 111, 80, 255]];
352 const EDGE_MARGIN_PIXELS: f64 = 2.0;
354 let frame_sizes = [
355 f64::from(FRAME_WIDTH_PIXELS),
356 f64::from(FRAME_HEIGHT_PIXELS),
357 ];
358 let mouth_world = calculate_mouth_position(body_spine);
359 let mouth = [0, 1].map(|axis_index| {
360 mouth_world[axis_index] - pose_center[axis_index] + frame_sizes[axis_index] / 2.0
361 });
362 let room = [0, 1].map(|axis_index| {
363 (mouth[axis_index] - EDGE_MARGIN_PIXELS)
364 .min(frame_sizes[axis_index] - 1.0 - EDGE_MARGIN_PIXELS - mouth[axis_index])
365 .max(0.0)
366 });
367 let forward_direction = calculate_forward_direction(body_spine, 0);
368 let frame_width = FRAME_WIDTH_PIXELS as usize;
369 let mut paint_water = |point: [f64; 2], color: [u8; 4], dither_limit: f64| {
370 let [pixel_x, pixel_y] = point.map(f64::floor);
371 if !(0.0..frame_sizes[0]).contains(&pixel_x) || !(0.0..frame_sizes[1]).contains(&pixel_y) {
372 return;
373 }
374 let pixel_index = pixel_y as usize * frame_width + pixel_x as usize;
375 if rgba_pixels[pixel_index][3] == 0
376 && calculate_dither_threshold([pixel_x as u32, pixel_y as u32]) < dither_limit
377 {
378 rgba_pixels[pixel_index] = color;
379 }
380 };
381 let largest_ring_width = koi_anatomy.body_length_pixels * RING_WIDTH_PER_BODY_LENGTH * 0.85;
382 let ring_scale = 1.0_f64
383 .min(room[0] / largest_ring_width)
384 .min(room[1] / (largest_ring_width * RING_HEIGHT_PER_WIDTH));
385 for ring_index in 0..RING_COUNT {
386 let ring_phase = (feeding_phase + ring_index as f64 / RING_COUNT as f64).fract();
387 let ring_width = ring_scale
388 * koi_anatomy.body_length_pixels
389 * RING_WIDTH_PER_BODY_LENGTH
390 * (0.25 + 0.6 * ring_phase);
391 let ring_color = WHITE_RAMP[if ring_index % 2 == 1 { 2 } else { 1 }];
392 let mut ring_angle: f64 = 0.0;
393 while ring_angle < TAU {
394 paint_water(
395 [
396 mouth[0] + ring_angle.cos() * ring_width,
397 mouth[1] + ring_angle.sin() * ring_width * RING_HEIGHT_PER_WIDTH,
398 ],
399 ring_color,
400 (1.0 - ring_phase) * 0.75,
401 );
402 ring_angle += RING_ANGLE_STEP_RADIANS;
403 }
404 }
405 let farthest_pellet_pixels = PELLET_NEAREST_PIXELS + PELLET_REACH_PIXELS + PELLET_WANDER_PIXELS;
407 let pellet_scale = 1.0_f64.min((room[0].min(room[1]) - 1.0).max(0.0) / farthest_pellet_pixels);
408 for pellet_index in 0..PELLET_COUNT {
409 let life = (feeding_phase + pellet_index as f64 / PELLET_COUNT as f64).fract();
410 if life >= PELLET_EATEN_LIFE {
411 continue;
412 }
413 let reach = PELLET_NEAREST_PIXELS + (1.0 - life) * PELLET_REACH_PIXELS;
414 let wander = (1.0 - life) * PELLET_WANDER_PIXELS;
415 let pellet_point = [
416 mouth[0]
417 + pellet_scale
418 * (forward_direction[0] * reach + (pellet_index as f64 * 2.1).sin() * wander),
419 mouth[1]
420 + pellet_scale
421 * (forward_direction[1] * reach + (pellet_index as f64 * 1.7).cos() * wander),
422 ];
423 paint_water(pellet_point, PELLET_COLORS[0], 1.0);
424 paint_water(
425 [pellet_point[0] + 1.0, pellet_point[1]],
426 PELLET_COLORS[1],
427 1.0,
428 );
429 }
430}
431
432fn paint_depth(rgba_pixels: &mut [[u8; 4]], fish_pixel_mask: &[bool], depth: f64) {
436 const DEEP_WATER: [f64; 3] = [18.0, 78.0, 137.0];
437 const MID_WATER: [f64; 3] = [0.0, 153.0, 219.0];
438 const SHALLOW_WATER: [f64; 3] = [190.0, 225.0, 245.0];
439 const OUTLINE_WATER: [f64; 3] = [24.0, 48.0, 92.0];
440 const FISH_DROPOUT_PER_DEPTH: f64 = 0.15;
441 const OUTLINE_DROPOUT_PER_DEPTH: f64 = 0.45;
442 const TINT_PER_DEPTH: f64 = 1.15;
443 let tint = (depth * TINT_PER_DEPTH).min(1.0);
444 let frame_width = FRAME_WIDTH_PIXELS as usize;
445 for (pixel_index, pixel_channels) in rgba_pixels.iter_mut().enumerate() {
446 if pixel_channels[3] == 0 {
447 continue;
448 }
449 let is_fish_pixel = fish_pixel_mask[pixel_index];
450 let dropout_per_depth = if is_fish_pixel {
451 FISH_DROPOUT_PER_DEPTH
452 } else {
453 OUTLINE_DROPOUT_PER_DEPTH
454 };
455 let pixel_position = [
456 (pixel_index % frame_width) as u32,
457 (pixel_index / frame_width) as u32,
458 ];
459 if calculate_dither_threshold(pixel_position) < depth * dropout_per_depth {
460 *pixel_channels = [0; 4];
461 continue;
462 }
463 let original_color =
464 [0, 1, 2].map(|channel_index| f64::from(pixel_channels[channel_index]));
465 let water_color = if is_fish_pixel {
466 let brightness =
467 (0.3 * original_color[0] + 0.59 * original_color[1] + 0.11 * original_color[2])
468 / 255.0;
469 let (from_color, to_color, blend) = if brightness < 0.5 {
470 (DEEP_WATER, MID_WATER, brightness * 2.0)
471 } else {
472 (MID_WATER, SHALLOW_WATER, brightness * 2.0 - 1.0)
473 };
474 [0, 1, 2].map(|channel_index| {
475 interpolate(from_color[channel_index], to_color[channel_index], blend)
476 })
477 } else {
478 OUTLINE_WATER
479 };
480 for channel_index in 0..3 {
481 pixel_channels[channel_index] = interpolate(
482 original_color[channel_index],
483 water_color[channel_index],
484 tint,
485 )
486 .round() as u8;
487 }
488 }
489}
490
491const OUTLINE_COLOR: [u8; 4] = [24, 20, 37, 255];
492const DITHER_RANKS: [[u8; 4]; 4] = [[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]];
493
494struct KoiAnatomy {
495 body_length_pixels: f64,
496 body_width_pixels: f64,
497 head_slant_fraction: f64,
499 nose_width_fraction: f64,
501 tail_taper_exponent: f64,
503 adult_fin_fraction: f64,
505 fin_length_multiplier: f64,
506 eye_along_fraction: f64,
507 eye_scale: f64,
509}
510
511struct KoiAppearance<'a> {
512 koi_anatomy: &'a KoiAnatomy,
513 koi_coat: &'a KoiCoat,
514}
515
516#[derive(Clone, Copy)]
518struct CurvePoint {
519 position: [f64; 2],
520 half_width_pixels: f64,
521}
522
523struct CurveSample {
524 along_fraction: f64,
525 lateral_pixels: f64,
526 distance_pixels: f64,
527 half_width_pixels: f64,
528 normal_direction: [f64; 2],
529}
530
531#[derive(Clone, Copy)]
532struct FinShape {
533 length_body_widths: f64,
534 half_width_body_widths: f64,
535 width_start_fraction: f64,
536 taper_exponent: f64,
537}
538
539impl FinShape {
540 fn blend(self, other: Self, other_fraction: f64) -> Self {
541 Self {
542 length_body_widths: interpolate(
543 self.length_body_widths,
544 other.length_body_widths,
545 other_fraction,
546 ),
547 half_width_body_widths: interpolate(
548 self.half_width_body_widths,
549 other.half_width_body_widths,
550 other_fraction,
551 ),
552 width_start_fraction: interpolate(
553 self.width_start_fraction,
554 other.width_start_fraction,
555 other_fraction,
556 ),
557 taper_exponent: interpolate(self.taper_exponent, other.taper_exponent, other_fraction),
558 }
559 }
560}
561
562struct FinPair {
566 fin_kind: FinKind,
567 root_along_fraction: f64,
568 root_side_fraction: f64,
569 young_shape: FinShape,
570 adult_shape: FinShape,
571 direction_offsets: [&'static [f64]; 2],
572}
573
574#[derive(Clone, Copy)]
575enum FinKind {
576 Tail,
577 Side,
578 Pelvic,
579}
580
581const FIN_PAIRS: [FinPair; 3] = [
582 FinPair {
583 fin_kind: FinKind::Tail,
584 root_along_fraction: 1.0,
585 root_side_fraction: 0.0,
586 young_shape: FinShape {
587 length_body_widths: 1.05,
588 half_width_body_widths: 0.3,
589 width_start_fraction: 0.16,
590 taper_exponent: 0.6,
591 },
592 adult_shape: FinShape {
593 length_body_widths: 1.62,
594 half_width_body_widths: 0.3,
595 width_start_fraction: 0.125,
596 taper_exponent: 0.69,
597 },
598 direction_offsets: [
599 &[-0.18035, -0.25648, -0.37367, -0.50421, -0.61344, -0.66997],
600 &[0.248, 0.30115, 0.29856, 0.24064, 0.14695, 0.05079],
601 ],
602 },
603 FinPair {
604 fin_kind: FinKind::Side,
605 root_along_fraction: 0.22,
606 root_side_fraction: 0.8,
607 young_shape: FinShape {
608 length_body_widths: 0.66,
609 half_width_body_widths: 0.25,
610 width_start_fraction: 0.18,
611 taper_exponent: 0.45,
612 },
613 adult_shape: FinShape {
614 length_body_widths: 1.13,
615 half_width_body_widths: 0.24,
616 width_start_fraction: 0.09,
617 taper_exponent: 0.54,
618 },
619 direction_offsets: [
620 &[0.87, 0.77239, 0.71583, 0.70044],
621 &[-0.65221, -0.58002, -0.5356, -0.48311],
622 ],
623 },
624 FinPair {
625 fin_kind: FinKind::Pelvic,
626 root_along_fraction: 0.47,
627 root_side_fraction: 0.7,
628 young_shape: FinShape {
629 length_body_widths: 0.36,
630 half_width_body_widths: 0.1,
631 width_start_fraction: 0.15,
632 taper_exponent: 0.5,
633 },
634 adult_shape: FinShape {
635 length_body_widths: 0.57,
636 half_width_body_widths: 0.11,
637 width_start_fraction: 0.11,
638 taper_exponent: 0.59,
639 },
640 direction_offsets: [&[0.39139, 0.3769, 0.39376], &[-0.62997, -0.48164, -0.32809]],
641 },
642];
643
644fn calculate_anatomy(total_contributions: u64) -> KoiAnatomy {
645 const MIN_BODY_LENGTH_PIXELS: f64 = 37.5;
646 const ADULT_BODY_LENGTH_PIXELS: f64 = 137.5;
647 const MAX_BODY_LENGTH_PIXELS: f64 = 167.5;
648 const YOUNG_BODY_WIDTH_FRACTION: f64 = 0.17;
649 const ADULT_BODY_WIDTH_FRACTION: f64 = 0.2;
650 const MATURE_BODY_WIDTH_GAIN: f64 = 0.045;
651 const YOUNG_HEAD_SLANT_FRACTION: f64 = 0.14;
652 const ADULT_HEAD_SLANT_FRACTION: f64 = 0.187;
653 const YOUNG_NOSE_WIDTH_FRACTION: f64 = 0.75;
654 const ADULT_NOSE_WIDTH_FRACTION: f64 = 0.517;
655 const YOUNG_TAIL_TAPER_EXPONENT: f64 = 1.25;
656 const ADULT_TAIL_TAPER_EXPONENT: f64 = 1.8;
657 const YOUNG_EYE_ALONG_FRACTION: f64 = 0.08;
658 const ADULT_EYE_ALONG_FRACTION: f64 = 0.06;
659 const YOUNG_EYE_SCALE: f64 = 2.1;
660 const YOUNG_FIN_LENGTH_MULTIPLIER: f64 = 0.85;
661 const MAX_FIN_LENGTH_MULTIPLIER: f64 = 1.1;
662 const YOUNG_FIN_HOLD_FRACTION: f64 = 0.25;
664 let growth_fraction = calculate_growth_fraction(total_contributions);
665 let adult_growth_fraction = calculate_growth_fraction(ADULT_CONTRIBUTIONS);
666 let young_growth_fraction = (growth_fraction / adult_growth_fraction).min(1.0);
667 let mature_growth_fraction =
668 ((growth_fraction - adult_growth_fraction) / (1.0 - adult_growth_fraction)).max(0.0);
669 let adult_shape_fraction =
670 (growth_fraction / calculate_growth_fraction(YOUNG_SHAPE_CONTRIBUTIONS)).min(1.0);
671 let body_length_pixels = MIN_BODY_LENGTH_PIXELS
672 + (ADULT_BODY_LENGTH_PIXELS - MIN_BODY_LENGTH_PIXELS) * young_growth_fraction
673 + (MAX_BODY_LENGTH_PIXELS - ADULT_BODY_LENGTH_PIXELS) * mature_growth_fraction;
674 let body_width_fraction = interpolate(
675 YOUNG_BODY_WIDTH_FRACTION,
676 ADULT_BODY_WIDTH_FRACTION,
677 adult_shape_fraction,
678 ) + MATURE_BODY_WIDTH_GAIN * mature_growth_fraction;
679 KoiAnatomy {
680 body_length_pixels,
681 body_width_pixels: body_length_pixels * body_width_fraction,
682 head_slant_fraction: interpolate(
683 YOUNG_HEAD_SLANT_FRACTION,
684 ADULT_HEAD_SLANT_FRACTION,
685 adult_shape_fraction,
686 ),
687 nose_width_fraction: interpolate(
688 YOUNG_NOSE_WIDTH_FRACTION,
689 ADULT_NOSE_WIDTH_FRACTION,
690 adult_shape_fraction,
691 ),
692 tail_taper_exponent: interpolate(
693 YOUNG_TAIL_TAPER_EXPONENT,
694 ADULT_TAIL_TAPER_EXPONENT,
695 adult_shape_fraction,
696 ),
697 adult_fin_fraction: smooth_step(
698 (adult_shape_fraction - YOUNG_FIN_HOLD_FRACTION) / (1.0 - YOUNG_FIN_HOLD_FRACTION),
699 ),
700 fin_length_multiplier: interpolate(
701 YOUNG_FIN_LENGTH_MULTIPLIER,
702 1.0,
703 smooth_step(adult_shape_fraction),
704 ) * interpolate(
705 1.0,
706 MAX_FIN_LENGTH_MULTIPLIER,
707 mature_growth_fraction,
708 ),
709 eye_along_fraction: interpolate(
710 YOUNG_EYE_ALONG_FRACTION,
711 ADULT_EYE_ALONG_FRACTION,
712 adult_shape_fraction,
713 ),
714 eye_scale: interpolate(YOUNG_EYE_SCALE, 1.0, adult_shape_fraction),
715 }
716}
717
718fn interpolate(from: f64, to: f64, fraction: f64) -> f64 {
719 from + (to - from) * fraction
720}
721
722fn smooth_step(fraction: f64) -> f64 {
724 let fraction = fraction.clamp(0.0, 1.0);
725 fraction * fraction * (3.0 - 2.0 * fraction)
726}
727
728fn build_spine(koi_anatomy: &KoiAnatomy, swim_pose: &SwimPose) -> Vec<CurvePoint> {
731 const HEADING_RADIANS: f64 = -0.08;
732 const BEND_RADIANS: f64 = 0.35;
733 const WAVE_AMPLITUDE_FRACTION: f64 = 0.06;
734 const WAVE_CYCLES: f64 = 0.9;
735 let body_length_pixels = koi_anatomy.body_length_pixels;
736 let point_count = body_length_pixels.round() as usize;
737 const MIN_HALF_WIDTH_PIXELS: f64 = 1.3;
738 let calculate_point_half_width = |point_index: usize| {
739 calculate_body_half_width(point_index as f64 / point_count as f64, koi_anatomy)
740 .max(MIN_HALF_WIDTH_PIXELS)
741 };
742 let center_points = match swim_pose.course {
743 Course::Hover {
744 heading_offset_radians,
745 ..
746 } => {
747 let mut center_points = vec![CurvePoint {
748 position: [0.0, 0.0],
749 half_width_pixels: calculate_point_half_width(0),
750 }];
751 for point_index in 1..point_count {
752 let backward_angle_radians = HEADING_RADIANS
753 + heading_offset_radians
754 + PI
755 + BEND_RADIANS * point_index as f64 / point_count as f64;
756 let [previous_x, previous_y] = center_points[point_index - 1].position;
757 center_points.push(CurvePoint {
758 position: [
759 previous_x + backward_angle_radians.cos(),
760 previous_y + backward_angle_radians.sin(),
761 ],
762 half_width_pixels: calculate_point_half_width(point_index),
763 });
764 }
765 center_points
766 }
767 Course::Lap { angle_radians } => {
769 let radius_pixels = calculate_lap_radius(body_length_pixels);
770 (0..point_count)
771 .map(|point_index| {
772 let point_angle_radians = angle_radians - point_index as f64 / radius_pixels;
773 CurvePoint {
774 position: [
775 radius_pixels * point_angle_radians.cos(),
776 radius_pixels * point_angle_radians.sin(),
777 ],
778 half_width_pixels: calculate_point_half_width(point_index),
779 }
780 })
781 .collect()
782 }
783 };
784 let mut body_spine = Vec::with_capacity(point_count);
785 for (point_index, center_point) in center_points.iter().enumerate() {
786 let along_fraction = point_index as f64 / point_count as f64;
787 let forward_direction = calculate_forward_direction(¢er_points, point_index);
788 let head_wave_fraction = 0.1;
789 let wave_envelope =
790 head_wave_fraction + (1.0 - head_wave_fraction) * along_fraction.powi(2);
791 let wave_offset = body_length_pixels
792 * WAVE_AMPLITUDE_FRACTION
793 * swim_pose.wave_strength
794 * wave_envelope
795 * (along_fraction * TAU * WAVE_CYCLES - swim_pose.wave_phase_radians).sin();
796 body_spine.push(CurvePoint {
797 position: [
798 center_point.position[0] - forward_direction[1] * wave_offset,
799 center_point.position[1] + forward_direction[0] * wave_offset,
800 ],
801 half_width_pixels: center_point.half_width_pixels,
802 });
803 }
804 body_spine
805}
806
807fn build_fins(
810 body_spine: &[CurvePoint],
811 koi_anatomy: &KoiAnatomy,
812 swim_pose: &SwimPose,
813) -> Vec<Vec<CurvePoint>> {
814 let mut fin_curves = Vec::new();
815 for fin_pair in &FIN_PAIRS {
816 let root_index =
817 (fin_pair.root_along_fraction * (body_spine.len() - 1) as f64).round() as usize;
818 let root_point = body_spine[root_index];
819 let forward_direction = calculate_forward_direction(body_spine, root_index);
820 let backward_angle_radians = (-forward_direction[1]).atan2(-forward_direction[0]);
821 for (side_index, (body_side, direction_offsets)) in [-1.0, 1.0]
822 .into_iter()
823 .zip(fin_pair.direction_offsets)
824 .enumerate()
825 {
826 let outward_sign = direction_offsets[0].signum();
827 let (root_turn_radians, bend_radians) = match fin_pair.fin_kind {
828 FinKind::Tail => (
829 outward_sign * swim_pose.tail_spread_radians,
830 swim_pose.tail_lobe_bends_radians[side_index],
831 ),
832 FinKind::Side => (
833 outward_sign * swim_pose.side_fin_openings_radians[side_index],
834 outward_sign * swim_pose.side_fin_bends_radians[side_index],
835 ),
836 FinKind::Pelvic => (outward_sign * swim_pose.pelvic_fin_opening_radians, 0.0),
837 };
838 let root_offset_pixels =
839 root_point.half_width_pixels * fin_pair.root_side_fraction * body_side;
840 let root_position = [
841 root_point.position[0] - forward_direction[1] * root_offset_pixels,
842 root_point.position[1] + forward_direction[0] * root_offset_pixels,
843 ];
844 fin_curves.push(build_fin(
845 fin_pair,
846 root_position,
847 backward_angle_radians + root_turn_radians,
848 koi_anatomy,
849 direction_offsets,
850 bend_radians,
851 ));
852 }
853 }
854 fin_curves
855}
856
857fn build_fin(
858 fin_pair: &FinPair,
859 root_position: [f64; 2],
860 backward_angle_radians: f64,
861 koi_anatomy: &KoiAnatomy,
862 direction_offsets: &[f64],
863 bend_radians: f64,
864) -> Vec<CurvePoint> {
865 let fin_shape = fin_pair
866 .young_shape
867 .blend(fin_pair.adult_shape, koi_anatomy.adult_fin_fraction);
868 let fin_length_pixels = koi_anatomy.body_width_pixels
869 * fin_shape.length_body_widths
870 * koi_anatomy.fin_length_multiplier;
871 let segment_count = fin_length_pixels.ceil() as usize;
872 let segment_length_pixels = fin_length_pixels / segment_count as f64;
873 let mut center_position = root_position;
874 let mut fin_points = Vec::new();
875 for point_index in 0..=segment_count {
876 let along_fraction = point_index as f64 / segment_count as f64;
877 let width_progress = fin_shape.width_start_fraction
878 + (1.0 - fin_shape.width_start_fraction) * along_fraction;
879 fin_points.push(CurvePoint {
880 position: center_position,
881 half_width_pixels: koi_anatomy.body_width_pixels
882 * fin_shape.half_width_body_widths
883 * (PI * width_progress)
884 .sin()
885 .max(0.0)
886 .powf(fin_shape.taper_exponent),
887 });
888 let direction_position = (along_fraction * direction_offsets.len() as f64 - 0.5)
889 .clamp(0.0, (direction_offsets.len() - 1) as f64);
890 let direction_index = direction_position.floor() as usize;
891 let start_angle = direction_offsets[direction_index];
892 let end_angle = direction_offsets[(direction_index + 1).min(direction_offsets.len() - 1)];
893 let segment_angle = backward_angle_radians
894 + start_angle
895 + (end_angle - start_angle) * direction_position.fract()
896 + bend_radians * along_fraction;
897 center_position[0] += segment_length_pixels * segment_angle.cos();
898 center_position[1] += segment_length_pixels * segment_angle.sin();
899 }
900 fin_points
901}
902
903fn paint_whiskers(rgba_pixels: &mut [[u8; 4]], whisker_points: &[[f64; 2]], pose_center: [f64; 2]) {
906 let frame_width = FRAME_WIDTH_PIXELS as usize;
907 for whisker_point in whisker_points {
908 let pixel_x =
909 (whisker_point[0] - pose_center[0] + f64::from(FRAME_WIDTH_PIXELS) / 2.0).floor();
910 let pixel_y =
911 (whisker_point[1] - pose_center[1] + f64::from(FRAME_HEIGHT_PIXELS) / 2.0).floor();
912 if !(0.0..f64::from(FRAME_WIDTH_PIXELS)).contains(&pixel_x)
913 || !(0.0..f64::from(FRAME_HEIGHT_PIXELS)).contains(&pixel_y)
914 {
915 continue;
916 }
917 let pixel_index = pixel_y as usize * frame_width + pixel_x as usize;
918 if rgba_pixels[pixel_index][3] == 0 {
919 rgba_pixels[pixel_index] = WHITE_RAMP[1];
920 }
921 }
922}
923
924fn calculate_whisker_points(
927 body_spine: &[CurvePoint],
928 koi_anatomy: &KoiAnatomy,
929 sway_radians: f64,
930) -> Vec<[f64; 2]> {
931 const SPREAD_RADIANS: f64 = 0.85;
932 const ROOT_SIDE_FRACTION: f64 = 0.8;
933 const ROOT_FORWARD_FRACTION: f64 = 0.55;
934 let whisker_length_pixels = calculate_whisker_length(koi_anatomy);
935 let snout_position = body_spine[0].position;
936 let snout_radius_pixels = body_spine[0].half_width_pixels;
937 let forward_direction = calculate_forward_direction(body_spine, 0);
938 let heading_radians = forward_direction[1].atan2(forward_direction[0]);
939 let mut whisker_points = Vec::new();
940 for body_side in [-1.0, 1.0] {
941 let side_offset_pixels = snout_radius_pixels * ROOT_SIDE_FRACTION * body_side;
942 let forward_offset_pixels = snout_radius_pixels * ROOT_FORWARD_FRACTION;
943 let root_position = [
944 snout_position[0] - forward_direction[1] * side_offset_pixels
945 + forward_direction[0] * forward_offset_pixels,
946 snout_position[1]
947 + forward_direction[0] * side_offset_pixels
948 + forward_direction[1] * forward_offset_pixels,
949 ];
950 let whisker_angle = heading_radians + sway_radians + body_side * SPREAD_RADIANS;
951 for step_pixels in 1..=whisker_length_pixels {
952 whisker_points.push([
953 root_position[0] + whisker_angle.cos() * f64::from(step_pixels),
954 root_position[1] + whisker_angle.sin() * f64::from(step_pixels),
955 ]);
956 }
957 }
958 whisker_points
959}
960
961fn calculate_whisker_length(koi_anatomy: &KoiAnatomy) -> u32 {
963 const BODY_PIXELS_PER_WHISKER_PIXEL: f64 = 34.0;
964 (koi_anatomy.body_length_pixels / BODY_PIXELS_PER_WHISKER_PIXEL)
965 .round()
966 .clamp(3.0, 5.0) as u32
967}
968
969fn calculate_lap_radius(body_length_pixels: f64) -> f64 {
973 const FRAME_REACH_PIXELS: f64 = 116.0;
974 const OUTER_REACH_FRACTION: f64 = 0.33;
975 const MAX_LAP_RADIUS_PIXELS: f64 = 88.0;
976 const MAX_BODY_SHARE_OF_LAP: f64 = 0.6;
977 (FRAME_REACH_PIXELS - OUTER_REACH_FRACTION * body_length_pixels)
978 .min(MAX_LAP_RADIUS_PIXELS)
979 .max(body_length_pixels / (TAU * MAX_BODY_SHARE_OF_LAP))
980}
981
982fn measure_curve_bounds(
984 body_spine: &[CurvePoint],
985 fin_curves: &[Vec<CurvePoint>],
986) -> [[f64; 2]; 2] {
987 let mut min_corner = [f64::INFINITY; 2];
988 let mut max_corner = [f64::NEG_INFINITY; 2];
989 for curve_point in body_spine.iter().chain(fin_curves.iter().flatten()) {
990 for axis_index in 0..2 {
991 min_corner[axis_index] = min_corner[axis_index]
992 .min(curve_point.position[axis_index] - curve_point.half_width_pixels);
993 max_corner[axis_index] = max_corner[axis_index]
994 .max(curve_point.position[axis_index] + curve_point.half_width_pixels);
995 }
996 }
997 [min_corner, max_corner]
998}
999
1000fn sample_koi(
1001 body_spine: &[CurvePoint],
1002 fin_curves: &[Vec<CurvePoint>],
1003 sample_position: [f64; 2],
1004 pixel_position: [u32; 2],
1005 koi_appearance: &KoiAppearance,
1006) -> [u8; 4] {
1007 if let Some(body_sample) = sample_curve(body_spine, sample_position) {
1008 return shade_body(&body_sample, pixel_position, koi_appearance);
1009 }
1010 for fin_curve in fin_curves {
1011 if let Some(fin_sample) = sample_curve(fin_curve, sample_position) {
1012 return shade_fin(&fin_sample, pixel_position, koi_appearance.koi_coat);
1013 }
1014 }
1015 [0; 4]
1016}
1017
1018fn sample_curve(curve_points: &[CurvePoint], sample_position: [f64; 2]) -> Option<CurveSample> {
1020 let mut closest_sample = None;
1021 let mut closest_distance_pixels = f64::INFINITY;
1022 for (segment_index, end_points) in curve_points.windows(2).enumerate() {
1023 let [start_point, end_point] = [end_points[0], end_points[1]];
1024 let segment_x = end_point.position[0] - start_point.position[0];
1025 let segment_y = end_point.position[1] - start_point.position[1];
1026 let offset_x = sample_position[0] - start_point.position[0];
1027 let offset_y = sample_position[1] - start_point.position[1];
1028 let segment_length_pixels = segment_x.hypot(segment_y);
1029 let segment_progress = ((offset_x * segment_x + offset_y * segment_y)
1030 / segment_length_pixels.powi(2))
1031 .clamp(0.0, 1.0);
1032 let distance_pixels = (offset_x - segment_progress * segment_x)
1033 .hypot(offset_y - segment_progress * segment_y);
1034 if distance_pixels >= closest_distance_pixels {
1035 continue;
1036 }
1037 closest_distance_pixels = distance_pixels;
1038 closest_sample = Some(CurveSample {
1039 along_fraction: (segment_index as f64 + segment_progress)
1040 / (curve_points.len() - 1) as f64,
1041 lateral_pixels: (offset_x * segment_y - offset_y * segment_x) / segment_length_pixels,
1042 distance_pixels,
1043 half_width_pixels: start_point.half_width_pixels
1044 + segment_progress * (end_point.half_width_pixels - start_point.half_width_pixels),
1045 normal_direction: [
1046 segment_y / segment_length_pixels,
1047 -segment_x / segment_length_pixels,
1048 ],
1049 });
1050 }
1051 closest_sample
1052 .filter(|curve_sample| curve_sample.distance_pixels <= curve_sample.half_width_pixels)
1053}
1054
1055fn shade_body(
1056 body_sample: &CurveSample,
1057 pixel_position: [u32; 2],
1058 koi_appearance: &KoiAppearance,
1059) -> [u8; 4] {
1060 let koi_anatomy = koi_appearance.koi_anatomy;
1061 let body_length_pixels = koi_anatomy.body_length_pixels;
1062 let lateral_fraction = body_sample.lateral_pixels / body_sample.half_width_pixels;
1063 let body_paint = koi_appearance.koi_coat.select_body_paint(
1064 body_sample.along_fraction,
1065 lateral_fraction,
1066 body_length_pixels,
1067 body_sample.half_width_pixels,
1068 calculate_dither_threshold(pixel_position),
1069 );
1070 const INNER_RIM_PIXELS: f64 = 0.8;
1071 if body_sample.distance_pixels > body_sample.half_width_pixels - INNER_RIM_PIXELS {
1072 return body_paint.shade_ramp[0];
1073 }
1074 let eye_lateral_fraction = 0.55;
1075 let eye_half_thickness_pixels = 0.8 * koi_anatomy.eye_scale;
1076 let eye_half_span_fraction = 0.25 * koi_anatomy.eye_scale.sqrt();
1077 if ((body_sample.along_fraction - koi_anatomy.eye_along_fraction) * body_length_pixels).abs()
1078 < eye_half_thickness_pixels
1079 && (lateral_fraction.abs() - eye_lateral_fraction).abs() < eye_half_span_fraction
1080 {
1081 return OUTLINE_COLOR;
1082 }
1083 if let Some(glint_color) = body_paint.glint_color {
1084 return glint_color;
1085 }
1086 let surface_brightness = calculate_body_brightness(body_sample, lateral_fraction, koi_anatomy)
1087 + body_paint.brightness_shift;
1088 adjust_saturation(
1089 choose_shade(body_paint.shade_ramp, surface_brightness, pixel_position),
1090 body_paint.saturation,
1091 )
1092}
1093
1094fn calculate_body_brightness(
1095 body_sample: &CurveSample,
1096 lateral_fraction: f64,
1097 koi_anatomy: &KoiAnatomy,
1098) -> f64 {
1099 const AMBIENT_BRIGHTNESS: f64 = 0.24;
1100 const DIFFUSE_STRENGTH: f64 = 0.7;
1101 const LIGHT_DIRECTION: [f64; 3] = [-0.32, -0.5, 0.8];
1102 let surface_normal_z = (1.0 - lateral_fraction.powi(2)).max(0.0).sqrt();
1103 let incident_light = lateral_fraction
1104 * (body_sample.normal_direction[0] * LIGHT_DIRECTION[0]
1105 + body_sample.normal_direction[1] * LIGHT_DIRECTION[1])
1106 + surface_normal_z * LIGHT_DIRECTION[2];
1107 let mut surface_brightness = AMBIENT_BRIGHTNESS + DIFFUSE_STRENGTH * incident_light.max(0.0);
1108 let dorsal_span = 0.27..0.62;
1109 let dorsal_half_width_fraction = 0.1;
1110 let dorsal_shadow = 0.18;
1111 if dorsal_span.contains(&body_sample.along_fraction)
1112 && lateral_fraction.abs() < dorsal_half_width_fraction
1113 {
1114 surface_brightness -= dorsal_shadow;
1115 }
1116 let gill_along_fraction = koi_anatomy.head_slant_fraction * 0.72;
1118 let gill_inner_fraction = 0.3;
1119 let gill_shadow = 0.22;
1120 if ((body_sample.along_fraction - gill_along_fraction) * koi_anatomy.body_length_pixels).abs()
1121 < 0.5
1122 && lateral_fraction.abs() > gill_inner_fraction
1123 {
1124 surface_brightness -= gill_shadow;
1125 }
1126 surface_brightness
1127}
1128
1129fn shade_fin(fin_sample: &CurveSample, pixel_position: [u32; 2], koi_coat: &KoiCoat) -> [u8; 4] {
1130 const FIN_RIM_PIXELS: f64 = 0.6;
1131 const FIN_RAY_SPACING_PIXELS: f64 = 1.8;
1132 const FIN_ROOT_SHADOW: f64 = 0.12;
1133 let is_on_rim = fin_sample.distance_pixels > fin_sample.half_width_pixels - FIN_RIM_PIXELS;
1134 let fin_paint = koi_coat.select_fin_paint(
1135 fin_sample.along_fraction,
1136 fin_sample.lateral_pixels / fin_sample.half_width_pixels,
1137 is_on_rim,
1138 calculate_dither_threshold(pixel_position),
1139 );
1140 if is_on_rim {
1141 return adjust_saturation(fin_paint.shade_ramp[1], fin_paint.saturation);
1142 }
1143 let ray_phase = (fin_sample.lateral_pixels / FIN_RAY_SPACING_PIXELS).rem_euclid(1.0);
1144 let ray_brightness = 0.68;
1145 let membrane_brightness = 0.94;
1146 let surface_brightness = if ray_phase < 0.5 {
1147 ray_brightness
1148 } else {
1149 membrane_brightness
1150 };
1151 let root_shadow = FIN_ROOT_SHADOW * (1.0 - fin_sample.along_fraction).powi(2);
1152 adjust_saturation(
1153 choose_shade(
1154 fin_paint.shade_ramp,
1155 surface_brightness - root_shadow,
1156 pixel_position,
1157 ),
1158 fin_paint.saturation,
1159 )
1160}
1161
1162fn calculate_body_half_width(along_fraction: f64, koi_anatomy: &KoiAnatomy) -> f64 {
1166 const MIN_SHOULDER_FRACTION: f64 = 0.3;
1167 const SHOULDER_GAP_FRACTION: f64 = 0.04;
1168 const TAPER_END_FRACTION: f64 = 0.92;
1169 const TAIL_WIDTH_FRACTION: f64 = 0.26;
1170 let head_slant_fraction = koi_anatomy.head_slant_fraction;
1171 let width_fraction = if along_fraction < head_slant_fraction {
1172 let nose_width_fraction = koi_anatomy.nose_width_fraction;
1173 nose_width_fraction
1174 + (1.0 - nose_width_fraction) * (along_fraction / head_slant_fraction * PI / 2.0).sin()
1175 } else {
1176 let shoulder_fraction =
1177 (head_slant_fraction + SHOULDER_GAP_FRACTION).max(MIN_SHOULDER_FRACTION);
1178 let taper_length_fraction = TAPER_END_FRACTION - shoulder_fraction;
1179 (1.0 - ((along_fraction - shoulder_fraction).max(0.0) / taper_length_fraction)
1180 .powf(koi_anatomy.tail_taper_exponent))
1181 .max(TAIL_WIDTH_FRACTION)
1182 };
1183 koi_anatomy.body_width_pixels / 2.0 * width_fraction
1184}
1185
1186fn calculate_forward_direction(curve_points: &[CurvePoint], point_index: usize) -> [f64; 2] {
1187 const DIRECTION_SPAN_SAMPLES: usize = 2;
1188 let earlier_point = curve_points[point_index.saturating_sub(DIRECTION_SPAN_SAMPLES)].position;
1189 let later_point =
1190 curve_points[(point_index + DIRECTION_SPAN_SAMPLES).min(curve_points.len() - 1)].position;
1191 let tangent_x = earlier_point[0] - later_point[0];
1192 let tangent_y = earlier_point[1] - later_point[1];
1193 let tangent_length_pixels = tangent_x.hypot(tangent_y);
1194 [
1195 tangent_x / tangent_length_pixels,
1196 tangent_y / tangent_length_pixels,
1197 ]
1198}
1199
1200fn paint_outline(rgba_pixels: &mut [[u8; 4]]) {
1201 let original_pixels = rgba_pixels.to_vec();
1202 let frame_width = FRAME_WIDTH_PIXELS as usize;
1203 for (pixel_index, pixel_channels) in rgba_pixels.iter_mut().enumerate() {
1204 if original_pixels[pixel_index][3] != 0 {
1205 continue;
1206 }
1207 let pixel_x = pixel_index % frame_width;
1208 let pixel_y = pixel_index / frame_width;
1209 let touches_shape = (pixel_x > 0 && original_pixels[pixel_index - 1][3] != 0)
1210 || (pixel_x + 1 < frame_width && original_pixels[pixel_index + 1][3] != 0)
1211 || (pixel_y > 0 && original_pixels[pixel_index - frame_width][3] != 0)
1212 || (pixel_y + 1 < FRAME_HEIGHT_PIXELS as usize
1213 && original_pixels[pixel_index + frame_width][3] != 0);
1214 if touches_shape {
1215 *pixel_channels = OUTLINE_COLOR;
1216 }
1217 }
1218}
1219
1220fn choose_shade(
1221 shade_ramp: &[[u8; 4]],
1222 surface_brightness: f64,
1223 pixel_position: [u32; 2],
1224) -> [u8; 4] {
1225 let shade_position = surface_brightness.clamp(0.0, 1.0) * (shade_ramp.len() - 1) as f64;
1226 let base_index = shade_position.floor() as usize;
1227 let shade_index = base_index
1228 + usize::from(shade_position.fract() > calculate_dither_threshold(pixel_position));
1229 shade_ramp[shade_index.min(shade_ramp.len() - 1)]
1230}
1231
1232fn adjust_saturation(pixel_color: [u8; 4], saturation: f64) -> [u8; 4] {
1234 let [red, green, blue, alpha] = pixel_color.map(f64::from);
1235 let gray = 0.3 * red + 0.59 * green + 0.11 * blue;
1236 let saturate = |channel: f64| {
1237 (gray + (channel - gray) * saturation)
1238 .round()
1239 .clamp(0.0, 255.0) as u8
1240 };
1241 [saturate(red), saturate(green), saturate(blue), alpha as u8]
1242}
1243
1244fn calculate_dither_threshold(pixel_position: [u32; 2]) -> f64 {
1246 let dither_x = pixel_position[0] as usize % DITHER_RANKS[0].len();
1247 let dither_y = pixel_position[1] as usize % DITHER_RANKS.len();
1248 (f64::from(DITHER_RANKS[dither_y][dither_x]) + 0.5)
1249 / (DITHER_RANKS.len() * DITHER_RANKS[0].len()) as f64
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255
1256 fn paint_mouth_alone(koi_body: KoiBody, mouth_opening: f64) -> Vec<[u8; 4]> {
1258 let koi_anatomy = calculate_anatomy(1_500);
1259 let body_spine = build_spine(&koi_anatomy, &STILL_POSE);
1260 let mut rgba_pixels = vec![[0; 4]; (FRAME_WIDTH_PIXELS * FRAME_HEIGHT_PIXELS) as usize];
1261 let mouth_position = calculate_mouth_position(&body_spine);
1262 paint_mouth(
1263 &mut rgba_pixels,
1264 &body_spine,
1265 koi_body,
1266 mouth_position,
1267 mouth_opening,
1268 );
1269 rgba_pixels
1270 }
1271
1272 fn center_pixel(rgba_pixels: &[[u8; 4]]) -> [u8; 4] {
1273 rgba_pixels
1274 [(FRAME_HEIGHT_PIXELS / 2 * FRAME_WIDTH_PIXELS + FRAME_WIDTH_PIXELS / 2) as usize]
1275 }
1276
1277 fn count_painted(rgba_pixels: &[[u8; 4]]) -> usize {
1278 rgba_pixels
1279 .iter()
1280 .filter(|pixel_channels| pixel_channels[3] != 0)
1281 .count()
1282 }
1283
1284 #[test]
1285 fn the_mouth_opens_as_a_body_colored_rim_around_a_dark_opening() {
1286 assert_eq!(count_painted(&paint_mouth_alone(KoiBody::White, 0.1)), 0);
1287 for (koi_body, rim_color) in [
1288 (KoiBody::White, [192, 203, 220, 255]),
1289 (KoiBody::Black, [58, 68, 102, 255]),
1290 ] {
1291 let open_mouth = paint_mouth_alone(koi_body, 1.0);
1292 assert_eq!(center_pixel(&open_mouth), OUTLINE_COLOR);
1293 assert!(open_mouth.contains(&rim_color), "{koi_body:?}");
1294 assert!(
1295 open_mouth
1296 .iter()
1297 .all(|&pixel| pixel == [0; 4] || pixel == OUTLINE_COLOR || pixel == rim_color)
1298 );
1299 }
1300 let half_open = count_painted(&paint_mouth_alone(KoiBody::White, 0.5));
1301 let wide_open = count_painted(&paint_mouth_alone(KoiBody::White, 1.0));
1302 assert!(0 < half_open && half_open < wide_open);
1303 }
1304}