feat: complete map viewer scene and static CTL pose preview
Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
# FParkan
|
||||
|
||||
Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает
|
||||
ресурсы установленной оригинальной игры. Сейчас приложение загружает миссию
|
||||
и показывает её ландшафт и модели в статическом окне Vulkan.
|
||||
ресурсы установленной оригинальной игры. Сейчас приложение показывает
|
||||
интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
|
||||
объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
|
||||
события. Это просмотр карты с полной графической сценой; прохождение миссии и
|
||||
игровое поведение юнитов пока не запускаются, а модели показываются в статической
|
||||
позе, заданной сохранёнными настройками узлов прототипа.
|
||||
|
||||
## Запуск
|
||||
|
||||
@@ -10,16 +14,45 @@
|
||||
репозиторий не входят.
|
||||
|
||||
```powershell
|
||||
cargo run -p fparkan-game -- `
|
||||
cargo run --release -p fparkan-game -- `
|
||||
--root 'C:\GOG Games\Parkan - Iron Strategy' `
|
||||
--mission 'MISSIONS\Autodemo.00\data.tma' --frames 240
|
||||
--mission 'MISSIONS\Autodemo.00\data.tma'
|
||||
```
|
||||
|
||||
`--frames` задаёт число кадров. `--preview-roots N` ограничивает число объектов,
|
||||
`--legacy-camera-capture` читает сохранённую матрицу камеры,
|
||||
`--static-animation-frame` и `--static-material-phase` выбирают позу и фазу
|
||||
материала. `--readback-out target/frame.raw` сохраняет последний кадр GPU.
|
||||
Без сохранённой камеры используется вид карты сверху.
|
||||
Окно работает в интерактивном режиме до закрытия и по умолчанию загружает все
|
||||
корневые объекты миссии (`--preview-roots N` ограничивает их число только для
|
||||
диагностики).
|
||||
`WASD` перемещают камеру относительно её направления, `E/Q` поднимают и
|
||||
опускают её, `Shift` ускоряет, `Ctrl` замедляет движение. Удержание правой
|
||||
кнопки мыши включает relative-look; движение мыши вращает yaw/pitch. При
|
||||
потере фокуса обзор отключается и нажатые клавиши сбрасываются. `Esc`
|
||||
отпускает обзор или закрывает окно.
|
||||
|
||||
В каталоге миссии ищется `sky.ske`. Если файл найден, приложение читает её
|
||||
расписание и `sky.wea`; связанные MAT0/WEAR-материалы и
|
||||
`effects.rlb/env_lightning` загружаются из ресурсов игры. Без `sky.ske`
|
||||
окружение отключено; если файл есть, отсутствие `sky.wea`, `env_lightning` или
|
||||
связанного материала считается ошибкой. Параметр `--atmosphere-seconds N`
|
||||
задаёт начальное время; по умолчанию используется начало расписания. Когда
|
||||
окружение загружено, небо, солнце и
|
||||
луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
|
||||
звуковые события обновляются в каждом кадре.
|
||||
|
||||
`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок;
|
||||
значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture`
|
||||
читает сохранённый захват камеры и запускает воспроизводимый режим без
|
||||
free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
|
||||
всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
|
||||
узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
|
||||
используется кадр `0`. Статическая поза не запускает игровой Control, AI или
|
||||
полное поведение юнитов.
|
||||
`--static-material-phase <u16>` выбирает фиксированную фазу материала.
|
||||
`--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation`
|
||||
включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении
|
||||
или ошибке. Readback сохраняет байты в формате surface без преобразования;
|
||||
размер — `width × height × 4`. Порядок каналов и encoding определяются значением
|
||||
`readback_format` в выводе; на текущем GPU обычно используется
|
||||
`VK_FORMAT_R8G8B8A8_UNORM`.
|
||||
|
||||
## Разработка
|
||||
|
||||
|
||||
@@ -2,18 +2,132 @@
|
||||
|
||||
layout(location = 0) in vec3 in_color;
|
||||
layout(location = 1) in vec2 in_uv;
|
||||
layout(location = 2) in vec2 in_detail_uv;
|
||||
layout(location = 3) in float in_overlay_alpha;
|
||||
layout(location = 4) in float in_fog_factor;
|
||||
layout(location = 5) in vec3 in_base_diffuse;
|
||||
layout(location = 6) in vec3 in_base_specular;
|
||||
layout(location = 7) in vec3 in_overlay_diffuse;
|
||||
layout(location = 8) in vec3 in_overlay_specular;
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D base_color;
|
||||
layout(set = 0, binding = 1) uniform sampler2D base_detail;
|
||||
layout(set = 0, binding = 2) uniform sampler2D overlay_color;
|
||||
layout(set = 0, binding = 3) uniform sampler2D overlay_detail;
|
||||
|
||||
layout(push_constant) uniform AlphaTestConstants {
|
||||
layout(offset = 64)
|
||||
float alpha_cutoff;
|
||||
} alpha_test;
|
||||
layout(std140, set = 0, binding = 4) uniform FrameConstants {
|
||||
mat4 clip_from_world;
|
||||
vec4 directional_direction[4];
|
||||
vec4 directional_rgb[4];
|
||||
vec4 directional_coefficients[4];
|
||||
vec4 point_position_range;
|
||||
vec4 point_rgb_active;
|
||||
vec4 point_coefficients_active;
|
||||
vec4 lighting_floor;
|
||||
vec4 fog_color;
|
||||
vec4 fog_distances;
|
||||
vec4 camera_position;
|
||||
} frame;
|
||||
|
||||
layout(push_constant) uniform MaterialConstants {
|
||||
layout(offset = 0) float alpha_cutoff;
|
||||
layout(offset = 4) float diffuse_alpha;
|
||||
layout(offset = 8) float overlay_diffuse_alpha;
|
||||
layout(offset = 12) float directional_r;
|
||||
layout(offset = 16) float directional_g;
|
||||
layout(offset = 20) float directional_b;
|
||||
layout(offset = 24) float additive_r;
|
||||
layout(offset = 28) float additive_g;
|
||||
layout(offset = 32) float additive_b;
|
||||
layout(offset = 36) float overlay_directional_r;
|
||||
layout(offset = 40) float overlay_directional_g;
|
||||
layout(offset = 44) float overlay_directional_b;
|
||||
layout(offset = 48) float overlay_additive_r;
|
||||
layout(offset = 52) float overlay_additive_g;
|
||||
layout(offset = 56) float overlay_additive_b;
|
||||
layout(offset = 60) float base_page_x;
|
||||
layout(offset = 64) float base_page_y;
|
||||
layout(offset = 68) float base_page_w;
|
||||
layout(offset = 72) float base_page_h;
|
||||
layout(offset = 76) float detail_page_x;
|
||||
layout(offset = 80) float detail_page_y;
|
||||
layout(offset = 84) float detail_page_w;
|
||||
layout(offset = 88) float detail_page_h;
|
||||
layout(offset = 92) float overlay_page_x;
|
||||
layout(offset = 96) float overlay_page_y;
|
||||
layout(offset = 100) float overlay_page_w;
|
||||
layout(offset = 104) float overlay_page_h;
|
||||
layout(offset = 108) float overlay_detail_page_x;
|
||||
layout(offset = 112) float overlay_detail_page_y;
|
||||
layout(offset = 116) float overlay_detail_page_w;
|
||||
layout(offset = 120) float overlay_detail_page_h;
|
||||
layout(offset = 124) float material_mode;
|
||||
} material;
|
||||
|
||||
void main() {
|
||||
out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
|
||||
if (out_color.a < alpha_test.alpha_cutoff) {
|
||||
vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
|
||||
+ in_uv * vec2(material.base_page_w, material.base_page_h);
|
||||
vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y)
|
||||
+ in_detail_uv * vec2(material.detail_page_w, material.detail_page_h);
|
||||
vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y)
|
||||
+ in_uv * vec2(material.overlay_page_w, material.overlay_page_h);
|
||||
vec2 overlay_detail_uv = vec2(material.overlay_detail_page_x, material.overlay_detail_page_y)
|
||||
+ in_detail_uv * vec2(material.overlay_detail_page_w, material.overlay_detail_page_h);
|
||||
vec4 base = texture(base_color, base_uv);
|
||||
vec4 detail = texture(base_detail, detail_uv);
|
||||
vec4 overlay = texture(overlay_color, overlay_uv);
|
||||
vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv);
|
||||
int mode = int(round(material.material_mode));
|
||||
int combiner_mode = mode & 3;
|
||||
|
||||
// Native sky mode 4 combines nebula and stars as
|
||||
// mix(TEX0.rgb, TEX1.rgb, TEX1.a) * DIFFUSE.rgb and carries DIFFUSE.a
|
||||
// through as the pass alpha.
|
||||
if (combiner_mode == 2) {
|
||||
vec3 sky_rgb = mix(base.rgb, detail.rgb, clamp(detail.a, 0.0, 1.0));
|
||||
out_color = vec4(sky_rgb * in_color, clamp(in_overlay_alpha, 0.0, 1.0));
|
||||
if (out_color.a < material.alpha_cutoff) {
|
||||
discard;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Native terrain mode 3 combines the base texture and the lightmap
|
||||
// without the regular terrain 2x combiner or NdotL calculation.
|
||||
if (combiner_mode == 3) {
|
||||
vec3 lightmap_lighting = max(
|
||||
vec3(material.additive_r, material.additive_g, material.additive_b),
|
||||
frame.lighting_floor.rgb
|
||||
);
|
||||
vec3 vertex_rgb = base.rgb * detail.rgb * lightmap_lighting * in_color;
|
||||
vec3 final_rgb = mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor);
|
||||
out_color = vec4(final_rgb, base.a * material.diffuse_alpha);
|
||||
if (out_color.a < material.alpha_cutoff) {
|
||||
discard;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
float overlay_alpha = clamp(
|
||||
in_overlay_alpha * material.overlay_diffuse_alpha,
|
||||
0.0,
|
||||
1.0
|
||||
);
|
||||
vec3 base_rgb = 2.0 * base.rgb * detail.rgb;
|
||||
vec3 overlay_rgb = 2.0 * overlay.rgb * overlay_detail_sample.rgb;
|
||||
vec3 base_lit_rgb = base_rgb * in_base_diffuse + in_base_specular;
|
||||
vec3 overlay_lit_rgb = overlay_rgb * in_overlay_diffuse + in_overlay_specular;
|
||||
vec3 lit_rgb = mix(base_lit_rgb, overlay_lit_rgb, overlay_alpha);
|
||||
vec3 vertex_rgb = lit_rgb * in_color;
|
||||
vec3 final_rgb = combiner_mode == 0
|
||||
? mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor)
|
||||
: vertex_rgb;
|
||||
float final_alpha = combiner_mode == 1
|
||||
? base.a * material.diffuse_alpha * in_overlay_alpha
|
||||
: base.a * material.diffuse_alpha;
|
||||
out_color = vec4(final_rgb, final_alpha);
|
||||
if (out_color.a < material.alpha_cutoff) {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -2,17 +2,273 @@
|
||||
|
||||
layout(location = 0) in vec3 in_position;
|
||||
layout(location = 1) in vec3 in_color;
|
||||
layout(location = 2) in vec2 in_uv;
|
||||
layout(location = 2) in vec3 in_normal;
|
||||
layout(location = 3) in vec2 in_uv;
|
||||
layout(location = 4) in vec2 in_detail_uv;
|
||||
layout(location = 5) in float in_overlay_alpha;
|
||||
|
||||
layout(location = 0) out vec3 out_color;
|
||||
layout(location = 1) out vec2 out_uv;
|
||||
layout(location = 2) out vec2 out_detail_uv;
|
||||
layout(location = 3) out float out_overlay_alpha;
|
||||
// Native D3D vertex fog is written to SPECULAR.a and interpolated by the
|
||||
// fixed-function rasterizer. Keep the existing varying slot, but carry only
|
||||
// that scalar instead of recomputing distance in the fragment stage.
|
||||
layout(location = 4) out float out_fog_factor;
|
||||
layout(location = 5) out vec3 out_base_diffuse;
|
||||
layout(location = 6) out vec3 out_base_specular;
|
||||
layout(location = 7) out vec3 out_overlay_diffuse;
|
||||
layout(location = 8) out vec3 out_overlay_specular;
|
||||
|
||||
layout(push_constant) uniform CameraConstants {
|
||||
layout(std140, set = 0, binding = 4) uniform FrameConstants {
|
||||
mat4 clip_from_world;
|
||||
} camera;
|
||||
vec4 directional_direction[4];
|
||||
vec4 directional_rgb[4];
|
||||
vec4 directional_coefficients[4];
|
||||
vec4 point_position_range;
|
||||
vec4 point_rgb_active;
|
||||
vec4 point_coefficients_active;
|
||||
vec4 lighting_floor;
|
||||
vec4 fog_color;
|
||||
vec4 fog_distances;
|
||||
vec4 camera_position;
|
||||
} frame;
|
||||
|
||||
layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
|
||||
vec4 base;
|
||||
vec4 overlay;
|
||||
} material_specular;
|
||||
|
||||
layout(push_constant) uniform MaterialConstants {
|
||||
layout(offset = 0) float alpha_cutoff;
|
||||
layout(offset = 4) float diffuse_alpha;
|
||||
layout(offset = 8) float overlay_diffuse_alpha;
|
||||
layout(offset = 12) float directional_r;
|
||||
layout(offset = 16) float directional_g;
|
||||
layout(offset = 20) float directional_b;
|
||||
layout(offset = 24) float additive_r;
|
||||
layout(offset = 28) float additive_g;
|
||||
layout(offset = 32) float additive_b;
|
||||
layout(offset = 36) float overlay_directional_r;
|
||||
layout(offset = 40) float overlay_directional_g;
|
||||
layout(offset = 44) float overlay_directional_b;
|
||||
layout(offset = 48) float overlay_additive_r;
|
||||
layout(offset = 52) float overlay_additive_g;
|
||||
layout(offset = 56) float overlay_additive_b;
|
||||
layout(offset = 60) float base_page_x;
|
||||
layout(offset = 64) float base_page_y;
|
||||
layout(offset = 68) float base_page_w;
|
||||
layout(offset = 72) float base_page_h;
|
||||
layout(offset = 76) float detail_page_x;
|
||||
layout(offset = 80) float detail_page_y;
|
||||
layout(offset = 84) float detail_page_w;
|
||||
layout(offset = 88) float detail_page_h;
|
||||
layout(offset = 92) float overlay_page_x;
|
||||
layout(offset = 96) float overlay_page_y;
|
||||
layout(offset = 100) float overlay_page_w;
|
||||
layout(offset = 104) float overlay_page_h;
|
||||
layout(offset = 108) float overlay_detail_page_x;
|
||||
layout(offset = 112) float overlay_detail_page_y;
|
||||
layout(offset = 116) float overlay_detail_page_w;
|
||||
layout(offset = 120) float overlay_detail_page_h;
|
||||
layout(offset = 124) float material_mode;
|
||||
} material;
|
||||
|
||||
struct LightingTerms {
|
||||
vec3 diffuse;
|
||||
vec3 specular;
|
||||
};
|
||||
|
||||
float native_range_compress(float value) {
|
||||
if (value <= 1.0) {
|
||||
return value;
|
||||
}
|
||||
if (value <= 7.0) {
|
||||
return value / 6.0 + 5.0 / 6.0;
|
||||
}
|
||||
return 2.0;
|
||||
}
|
||||
|
||||
float native_specular_curve(float value) {
|
||||
if (value <= 1.0) {
|
||||
return 0.8 * value;
|
||||
}
|
||||
if (value <= 3.0) {
|
||||
return 0.1 * value + 0.7;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// The original callback uses the reflected vector and squares the cosine for
|
||||
// power-1 iterations. A bounded loop keeps the native byte power while
|
||||
// remaining valid for every material record.
|
||||
float native_specular_power(float cosine, float power) {
|
||||
// CShade enables specular only when the material power byte is non-zero.
|
||||
if (power <= 0.0 || cosine <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
float result = cosine;
|
||||
int iterations = clamp(int(power) - 1, 0, 254);
|
||||
for (int index = 0; index < 254; ++index) {
|
||||
if (index >= iterations) {
|
||||
break;
|
||||
}
|
||||
result *= result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
float native_fog_factor(vec3 world_position) {
|
||||
float fog_span = frame.fog_distances.y - frame.fog_distances.x;
|
||||
float distance_to_camera = distance(world_position, frame.camera_position.xyz);
|
||||
if (fog_span <= 0.000001 || distance_to_camera <= frame.fog_distances.x) {
|
||||
return 0.0;
|
||||
}
|
||||
return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 normalized_or(vec3 value, vec3 fallback) {
|
||||
float length_squared = dot(value, value);
|
||||
return length_squared > 0.0000001 ? value * inversesqrt(length_squared) : fallback;
|
||||
}
|
||||
|
||||
vec3 specular_from_light(
|
||||
vec3 normal,
|
||||
vec3 view_direction,
|
||||
vec3 light_direction,
|
||||
vec3 light_rgb,
|
||||
vec3 material_rgb,
|
||||
float power,
|
||||
float attenuation
|
||||
) {
|
||||
float ndotl = dot(normal, light_direction);
|
||||
if (ndotl <= 0.0 || attenuation <= 0.0) {
|
||||
return vec3(0.0);
|
||||
}
|
||||
vec3 reflection = reflect(-light_direction, normal);
|
||||
float cosine = max(dot(reflection, view_direction), 0.0);
|
||||
return material_rgb * light_rgb * native_specular_power(cosine, power) * attenuation;
|
||||
}
|
||||
|
||||
LightingTerms native_material_lighting(
|
||||
vec3 world_position,
|
||||
vec3 normal,
|
||||
vec3 directional_rgb,
|
||||
vec3 additive_rgb,
|
||||
vec3 specular_rgb,
|
||||
float specular_power
|
||||
) {
|
||||
vec3 accumulated = additive_rgb;
|
||||
vec3 specular = vec3(0.0);
|
||||
vec3 view_direction = normalized_or(frame.camera_position.xyz - world_position, vec3(0.0, 0.0, 1.0));
|
||||
for (int index = 0; index < 4; ++index) {
|
||||
vec4 direction_record = frame.directional_direction[index];
|
||||
vec4 rgb_record = frame.directional_rgb[index];
|
||||
if (direction_record.w <= 0.5 || rgb_record.w <= 0.5) {
|
||||
continue;
|
||||
}
|
||||
vec3 light_direction = normalized_or(-direction_record.xyz, vec3(0.0, 0.0, 1.0));
|
||||
float ndotl = max(dot(normal, light_direction), 0.0);
|
||||
accumulated += ndotl * directional_rgb * rgb_record.rgb;
|
||||
specular += specular_from_light(
|
||||
normal,
|
||||
view_direction,
|
||||
light_direction,
|
||||
rgb_record.rgb,
|
||||
specular_rgb,
|
||||
specular_power,
|
||||
1.0
|
||||
);
|
||||
}
|
||||
vec4 point_position_range = frame.point_position_range;
|
||||
vec4 point_rgb_active = frame.point_rgb_active;
|
||||
vec4 point_coefficients_active = frame.point_coefficients_active;
|
||||
vec3 to_light = point_position_range.xyz - world_position;
|
||||
float distance_squared = dot(to_light, to_light);
|
||||
float range = point_position_range.w;
|
||||
if (point_rgb_active.w > 0.5 && range > 0.0 && distance_squared <= range * range) {
|
||||
float distance_to_light = sqrt(distance_squared);
|
||||
if (distance_to_light > 0.000001) {
|
||||
vec3 light_direction = to_light / distance_to_light;
|
||||
float ndotl = dot(normal, light_direction);
|
||||
if (ndotl > 0.0) {
|
||||
float t = (range - distance_to_light) / range;
|
||||
float attenuation = point_coefficients_active.x
|
||||
+ point_coefficients_active.y * t
|
||||
+ point_coefficients_active.z * t * t;
|
||||
attenuation = max(attenuation, 0.0);
|
||||
accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation;
|
||||
specular += specular_from_light(
|
||||
normal,
|
||||
view_direction,
|
||||
light_direction,
|
||||
point_rgb_active.rgb,
|
||||
specular_rgb,
|
||||
specular_power,
|
||||
attenuation
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
accumulated = max(accumulated, frame.lighting_floor.rgb);
|
||||
vec3 compressed = vec3(
|
||||
native_range_compress(accumulated.r),
|
||||
native_range_compress(accumulated.g),
|
||||
native_range_compress(accumulated.b)
|
||||
);
|
||||
LightingTerms terms;
|
||||
terms.diffuse = min(compressed, vec3(1.0));
|
||||
vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));
|
||||
terms.specular = vec3(
|
||||
native_specular_curve(total_specular.r),
|
||||
native_specular_curve(total_specular.g),
|
||||
native_specular_curve(total_specular.b)
|
||||
);
|
||||
return terms;
|
||||
}
|
||||
|
||||
void main() {
|
||||
out_color = in_color;
|
||||
out_uv = in_uv;
|
||||
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
|
||||
out_detail_uv = in_detail_uv;
|
||||
out_overlay_alpha = in_overlay_alpha;
|
||||
out_fog_factor = native_fog_factor(in_position);
|
||||
int mode = int(round(material.material_mode));
|
||||
int combiner_mode = mode & 3;
|
||||
if (combiner_mode == 0) {
|
||||
LightingTerms base_terms = native_material_lighting(
|
||||
in_position,
|
||||
in_normal,
|
||||
vec3(material.directional_r, material.directional_g, material.directional_b),
|
||||
vec3(material.additive_r, material.additive_g, material.additive_b),
|
||||
material_specular.base.rgb,
|
||||
material_specular.base.w
|
||||
);
|
||||
LightingTerms overlay_terms = native_material_lighting(
|
||||
in_position,
|
||||
in_normal,
|
||||
vec3(material.overlay_directional_r, material.overlay_directional_g, material.overlay_directional_b),
|
||||
vec3(material.overlay_additive_r, material.overlay_additive_g, material.overlay_additive_b),
|
||||
material_specular.overlay.rgb,
|
||||
material_specular.overlay.w
|
||||
);
|
||||
out_base_diffuse = base_terms.diffuse;
|
||||
out_base_specular = base_terms.specular;
|
||||
out_overlay_diffuse = overlay_terms.diffuse;
|
||||
out_overlay_specular = overlay_terms.specular;
|
||||
} else {
|
||||
out_base_diffuse = vec3(1.0);
|
||||
out_base_specular = vec3(0.0);
|
||||
out_overlay_diffuse = vec3(1.0);
|
||||
out_overlay_specular = vec3(0.0);
|
||||
}
|
||||
vec4 clip_position = frame.clip_from_world * vec4(in_position, 1.0);
|
||||
// Sky far-depth is a Vulkan projection choice for layers authored around
|
||||
// the camera. Keep world positions, normals, and UVs intact while placing
|
||||
// the resulting primitive at the far depth; this does not assert native
|
||||
// D3D depth-bit equivalence.
|
||||
if ((mode & 4) != 0) {
|
||||
clip_position.z = clip_position.w;
|
||||
}
|
||||
gl_Position = clip_position;
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -21,16 +21,22 @@ mod swapchain_resources;
|
||||
mod validation;
|
||||
|
||||
pub use self::asset_mesh::{
|
||||
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space,
|
||||
node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
|
||||
node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh,
|
||||
project_land_msh_to_static_mesh_in_legacy_world_space,
|
||||
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
|
||||
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
|
||||
project_msh_to_static_mesh_in_world_space_with_transform,
|
||||
project_msh_to_static_mesh_in_xy_frame,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
|
||||
VulkanStaticXyFrame,
|
||||
VulkanNodePose, VulkanStaticXyFrame,
|
||||
};
|
||||
pub use self::capabilities::{
|
||||
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
|
||||
@@ -45,21 +51,27 @@ pub use self::instance::{
|
||||
use self::instance::{cstring_vec, ensure_instance_extensions_available};
|
||||
use self::resources::{
|
||||
color_subresource_range, create_command_pool, create_depth_attachment, create_frame_sync,
|
||||
create_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer,
|
||||
create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
|
||||
create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image,
|
||||
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage,
|
||||
VulkanDepthAttachment, VulkanFrameSync,
|
||||
create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer,
|
||||
destroy_allocated_image, destroy_depth_attachment, readback_buffer_bytes,
|
||||
update_static_texture_image, write_frame_uniform, write_material_specular_buffer,
|
||||
write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer,
|
||||
VulkanAllocatedImage, VulkanDepthAttachment, VulkanFrameSync,
|
||||
};
|
||||
pub use self::runtime::{
|
||||
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
|
||||
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
|
||||
};
|
||||
pub use self::smoke_types::{
|
||||
VulkanDirectionalLight, VulkanDynamicDrawRange, VulkanFrameUniforms, VulkanPointLight,
|
||||
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
|
||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
||||
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
|
||||
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
|
||||
VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
|
||||
VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, VulkanValidationReport,
|
||||
VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_UNIFORM_BYTES,
|
||||
VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::surface::extension_name;
|
||||
@@ -72,7 +84,8 @@ pub use self::swapchain::{
|
||||
VulkanSwapchainProbeError, VulkanSwapchainReport,
|
||||
};
|
||||
use self::swapchain_resources::{
|
||||
create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources,
|
||||
create_swapchain_resources, destroy_swapchain_resources, update_material_texture_descriptor,
|
||||
VulkanSwapchainResources,
|
||||
};
|
||||
use self::validation::{create_validation_messenger, VulkanValidationMessenger};
|
||||
use ash::vk;
|
||||
@@ -469,6 +482,8 @@ const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
|
||||
];
|
||||
|
||||
const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT] {
|
||||
assert!(bytes.len() % 4 == 0);
|
||||
assert!(bytes.len() == WORD_COUNT * 4);
|
||||
let mut words = [0_u32; WORD_COUNT];
|
||||
let mut index = 0;
|
||||
while index < WORD_COUNT {
|
||||
@@ -484,10 +499,12 @@ const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT]
|
||||
words
|
||||
}
|
||||
|
||||
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] =
|
||||
spirv_words(include_bytes!("../shaders/triangle.vert.spv"));
|
||||
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] =
|
||||
spirv_words(include_bytes!("../shaders/triangle.frag.spv"));
|
||||
const TRIANGLE_VERTEX_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.vert.spv");
|
||||
const TRIANGLE_FRAGMENT_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.frag.spv");
|
||||
static TRIANGLE_VERTEX_SHADER_DATA: [u32; TRIANGLE_VERTEX_SHADER_BYTES.len() / 4] =
|
||||
spirv_words(TRIANGLE_VERTEX_SHADER_BYTES);
|
||||
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; TRIANGLE_FRAGMENT_SHADER_BYTES.len() / 4] =
|
||||
spirv_words(TRIANGLE_FRAGMENT_SHADER_BYTES);
|
||||
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
|
||||
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ pub(super) struct VulkanAllocatedImage {
|
||||
pub(super) image: vk::Image,
|
||||
pub(super) memory: vk::DeviceMemory,
|
||||
pub(super) view: vk::ImageView,
|
||||
pub(super) width: u32,
|
||||
pub(super) height: u32,
|
||||
pub(super) mip_levels: u32,
|
||||
}
|
||||
|
||||
/// Depth/stencil image and the exact format selected for its render pass.
|
||||
@@ -134,6 +137,9 @@ pub(super) fn create_depth_attachment(
|
||||
image,
|
||||
memory,
|
||||
view,
|
||||
width: extent.0,
|
||||
height: extent.1,
|
||||
mip_levels: 1,
|
||||
},
|
||||
format,
|
||||
})
|
||||
@@ -186,7 +192,6 @@ fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
|
||||
|
||||
pub(super) struct VulkanFrameSync {
|
||||
pub(super) image_available: vk::Semaphore,
|
||||
pub(super) render_finished: vk::Semaphore,
|
||||
pub(super) fence: vk::Fence,
|
||||
}
|
||||
|
||||
@@ -210,17 +215,7 @@ pub(super) fn create_static_mesh_vertex_buffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
mesh: &VulkanStaticMesh,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
|
||||
for vertex in &mesh.vertices {
|
||||
for value in vertex
|
||||
.position
|
||||
.into_iter()
|
||||
.chain(vertex.color)
|
||||
.chain(vertex.uv)
|
||||
{
|
||||
bytes.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
let bytes = encode_static_vertices(&mesh.vertices)?;
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
@@ -230,34 +225,176 @@ pub(super) fn create_static_mesh_vertex_buffer(
|
||||
)
|
||||
}
|
||||
|
||||
/// Allocates a vertex buffer for a frame's retained mesh capacity.
|
||||
pub(super) fn create_vertex_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
vertices: &[super::VulkanStaticVertex],
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let bytes = encode_static_vertices(vertices)?;
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::VERTEX_BUFFER,
|
||||
"dynamic mesh vertex buffer",
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces the bytes of a host-visible static vertex allocation.
|
||||
///
|
||||
/// The live renderer waits for the device before calling this helper, so a
|
||||
/// single shared allocation can safely carry camera-relative sky and weather
|
||||
/// geometry between frames.
|
||||
pub(super) fn write_static_mesh_vertex_buffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
vertices: &[super::VulkanStaticVertex],
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let bytes = encode_static_vertices(vertices)?;
|
||||
write_mapped_buffer(
|
||||
device,
|
||||
buffer,
|
||||
&bytes,
|
||||
"vkMapMemory(static mesh vertex update)",
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_static_vertices(
|
||||
vertices: &[super::VulkanStaticVertex],
|
||||
) -> Result<Vec<u8>, VulkanSmokeRendererError> {
|
||||
let byte_len = vertices
|
||||
.len()
|
||||
.checked_mul(14)
|
||||
.and_then(|count| count.checked_mul(std::mem::size_of::<f32>()))
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "static mesh vertex data size overflows address space",
|
||||
})?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes
|
||||
.try_reserve_exact(byte_len)
|
||||
.map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "static mesh vertex data exceeds addressable memory",
|
||||
})?;
|
||||
for vertex in vertices {
|
||||
for value in vertex
|
||||
.position
|
||||
.into_iter()
|
||||
.chain(vertex.color)
|
||||
.chain(vertex.normal)
|
||||
.chain(vertex.uv)
|
||||
.chain(vertex.detail_uv)
|
||||
.chain([vertex.overlay_alpha])
|
||||
{
|
||||
bytes.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn write_mapped_buffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
bytes: &[u8],
|
||||
context: &'static str,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let byte_len =
|
||||
u64::try_from(bytes.len()).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "static mesh upload exceeds address space",
|
||||
})?;
|
||||
if byte_len == 0 {
|
||||
return Err(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "static mesh upload cannot be empty",
|
||||
});
|
||||
}
|
||||
// SAFETY: The caller validates that the allocation is large enough and
|
||||
// waits for all queue work that could read it before this write.
|
||||
let mapped = unsafe {
|
||||
device
|
||||
.device()
|
||||
.map_memory(buffer.memory, 0, byte_len, vk::MemoryMapFlags::empty())
|
||||
}
|
||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation { context, result })?;
|
||||
// SAFETY: `mapped` covers exactly the byte slice copied below.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
|
||||
device.device().unmap_memory(buffer.memory);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn create_static_mesh_index_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
mesh: &VulkanStaticMesh,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
|
||||
for &index in &mesh.indices {
|
||||
bytes.extend_from_slice(&index.to_ne_bytes());
|
||||
create_index_buffer(instance, device, &mesh.indices)
|
||||
}
|
||||
|
||||
/// Allocates an index buffer for a frame's retained mesh capacity.
|
||||
pub(super) fn create_index_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
indices: &[u32],
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let bytes = encode_static_indices(indices)?;
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::INDEX_BUFFER,
|
||||
"static mesh index buffer",
|
||||
"dynamic mesh index buffer",
|
||||
)
|
||||
}
|
||||
|
||||
/// Replaces the bytes of a host-visible static index allocation.
|
||||
pub(super) fn write_static_mesh_index_buffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
indices: &[u32],
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let bytes = encode_static_indices(indices)?;
|
||||
write_mapped_buffer(
|
||||
device,
|
||||
buffer,
|
||||
&bytes,
|
||||
"vkMapMemory(static mesh index update)",
|
||||
)
|
||||
}
|
||||
|
||||
fn encode_static_indices(indices: &[u32]) -> Result<Vec<u8>, VulkanSmokeRendererError> {
|
||||
let byte_len = indices
|
||||
.len()
|
||||
.checked_mul(std::mem::size_of::<u32>())
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "static mesh index data size overflows address space",
|
||||
})?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes
|
||||
.try_reserve_exact(byte_len)
|
||||
.map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "static mesh index data exceeds addressable memory",
|
||||
})?;
|
||||
for &index in indices {
|
||||
bytes.extend_from_slice(&index.to_ne_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(super) fn create_static_texture_image(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
texture: &VulkanStaticTexture,
|
||||
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> {
|
||||
texture
|
||||
.validate()
|
||||
.map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
|
||||
let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture);
|
||||
let staging = create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&texture.rgba8,
|
||||
&mip_bytes,
|
||||
vk::BufferUsageFlags::TRANSFER_SRC,
|
||||
"static texture staging buffer",
|
||||
)?;
|
||||
@@ -269,7 +406,7 @@ pub(super) fn create_static_texture_image(
|
||||
height: texture.height,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.mip_levels(mip_levels)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
@@ -336,6 +473,7 @@ pub(super) fn create_static_texture_image(
|
||||
image,
|
||||
texture.width,
|
||||
texture.height,
|
||||
mip_levels,
|
||||
) {
|
||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
||||
unsafe {
|
||||
@@ -350,7 +488,7 @@ pub(super) fn create_static_texture_image(
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(vk::Format::R8G8B8A8_UNORM)
|
||||
.subresource_range(color_subresource_range());
|
||||
.subresource_range(color_subresource_range(mip_levels));
|
||||
// SAFETY: The image is live, initialized and has the stated color subresource.
|
||||
let view = {
|
||||
// SAFETY: The image is live, initialized and has the stated color subresource.
|
||||
@@ -371,9 +509,211 @@ pub(super) fn create_static_texture_image(
|
||||
image,
|
||||
memory,
|
||||
view,
|
||||
width: texture.width,
|
||||
height: texture.height,
|
||||
mip_levels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Allocates two aligned camera slots used by the dynamic frame UBO.
|
||||
pub(super) fn create_frame_uniform_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> {
|
||||
const FRAME_SLOTS: u64 = 2;
|
||||
const FRAME_BYTES: u64 = super::VULKAN_FRAME_UNIFORM_BYTES as u64;
|
||||
let limits =
|
||||
// SAFETY: The physical device belongs to this live instance and the query returns a value copy.
|
||||
unsafe { instance.instance.get_physical_device_properties(device.physical_device()) };
|
||||
let alignment = u64::from(limits.limits.min_uniform_buffer_offset_alignment).max(1);
|
||||
let stride = FRAME_BYTES.checked_add(alignment - 1).ok_or(
|
||||
VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "frame uniform stride overflow",
|
||||
},
|
||||
)? / alignment
|
||||
* alignment;
|
||||
let size =
|
||||
stride
|
||||
.checked_mul(FRAME_SLOTS)
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "frame uniform buffer size overflow",
|
||||
})?;
|
||||
let bytes = vec![
|
||||
0_u8;
|
||||
usize::try_from(size).map_err(|_| {
|
||||
VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "frame uniform buffer size exceeds address space",
|
||||
}
|
||||
})?
|
||||
];
|
||||
let buffer = create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::UNIFORM_BUFFER,
|
||||
"frame uniform buffer",
|
||||
)?;
|
||||
Ok((buffer, stride))
|
||||
}
|
||||
|
||||
/// Allocates one aligned storage-buffer record for every material descriptor.
|
||||
///
|
||||
/// The descriptor uses a dynamic storage-buffer offset. Keeping the stride
|
||||
/// aligned to the physical device limit lets the renderer select a material
|
||||
/// record with the same descriptor set for every draw without baking material
|
||||
/// indices into the pipeline layout.
|
||||
pub(super) fn create_material_specular_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
material_count: usize,
|
||||
) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> {
|
||||
if material_count == 0 {
|
||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "material specular buffer has no material records",
|
||||
});
|
||||
}
|
||||
let limits =
|
||||
// SAFETY: The physical device belongs to this live instance and the query returns a value copy.
|
||||
unsafe { instance.instance.get_physical_device_properties(device.physical_device()) };
|
||||
let alignment = limits.limits.min_storage_buffer_offset_alignment.max(1);
|
||||
let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64;
|
||||
let stride = record_bytes.checked_add(alignment - 1).ok_or(
|
||||
VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "material specular stride overflow",
|
||||
},
|
||||
)? / alignment
|
||||
* alignment;
|
||||
let count =
|
||||
u64::try_from(material_count).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "material specular count exceeds address space",
|
||||
})?;
|
||||
let size = stride
|
||||
.checked_mul(count)
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "material specular buffer size overflow",
|
||||
})?;
|
||||
let bytes =
|
||||
vec![
|
||||
0_u8;
|
||||
usize::try_from(size).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "material specular buffer size exceeds address space",
|
||||
})?
|
||||
];
|
||||
let buffer = create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::STORAGE_BUFFER,
|
||||
"material specular buffer",
|
||||
)?;
|
||||
Ok((buffer, stride))
|
||||
}
|
||||
|
||||
/// Replaces all material specular records in one host-coherent upload.
|
||||
pub(super) fn write_material_specular_buffer(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
stride: u64,
|
||||
records: &[[[f32; 4]; 2]],
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
if records.is_empty() {
|
||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "material specular upload has no records",
|
||||
});
|
||||
}
|
||||
let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES;
|
||||
let total_bytes = stride
|
||||
.checked_mul(u64::try_from(records.len()).unwrap_or(u64::MAX))
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "material specular upload size",
|
||||
})?;
|
||||
let total_bytes_usize =
|
||||
usize::try_from(total_bytes).map_err(|_| VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "material specular upload size exceeds address space",
|
||||
})?;
|
||||
let mut bytes = vec![0_u8; total_bytes_usize];
|
||||
for (record_index, record) in records.iter().enumerate() {
|
||||
let record_offset = usize::try_from(
|
||||
stride
|
||||
.checked_mul(u64::try_from(record_index).unwrap_or(u64::MAX))
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "material specular upload offset",
|
||||
})?,
|
||||
)
|
||||
.map_err(|_| VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "material specular upload offset exceeds address space",
|
||||
})?;
|
||||
let mut offset = record_offset;
|
||||
for vector in record {
|
||||
for value in vector {
|
||||
bytes[offset..offset + std::mem::size_of::<f32>()]
|
||||
.copy_from_slice(&value.to_ne_bytes());
|
||||
offset += std::mem::size_of::<f32>();
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(offset - record_offset, record_bytes);
|
||||
}
|
||||
// SAFETY: The storage buffer is host-visible and the caller has waited for
|
||||
// all prior uses before replacing its records.
|
||||
let mapped = unsafe {
|
||||
device
|
||||
.device()
|
||||
.map_memory(buffer.memory, 0, total_bytes, vk::MemoryMapFlags::empty())
|
||||
}
|
||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkMapMemory(material specular buffer)",
|
||||
result,
|
||||
})?;
|
||||
// SAFETY: The mapped range is the complete upload and the destination is
|
||||
// valid for the exact byte count copied below.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
|
||||
device.device().unmap_memory(buffer.memory);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes one complete frame block into the slot selected by the
|
||||
/// fence-safe frame index.
|
||||
pub(super) fn write_frame_uniform(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
stride: u64,
|
||||
frame_index: usize,
|
||||
bytes: &[u8; super::VULKAN_FRAME_UNIFORM_BYTES],
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let slot =
|
||||
u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "frame uniform slot index",
|
||||
})?;
|
||||
let offset = stride
|
||||
.checked_mul(slot)
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "frame uniform slot offset",
|
||||
})?;
|
||||
// SAFETY: The offset is one of the two allocated aligned slots and the
|
||||
// mapped range is exactly one complete frame block.
|
||||
let mapped = unsafe {
|
||||
device.device().map_memory(
|
||||
buffer.memory,
|
||||
offset,
|
||||
super::VULKAN_FRAME_UNIFORM_BYTES as u64,
|
||||
vk::MemoryMapFlags::empty(),
|
||||
)
|
||||
}
|
||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkMapMemory(frame uniform buffer)",
|
||||
result,
|
||||
})?;
|
||||
// SAFETY: The destination is the mapped frame slot and the byte slice lives
|
||||
// for this copy.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
|
||||
device.device().unmap_memory(buffer.memory);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload_static_texture(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
@@ -381,6 +721,7 @@ fn upload_static_texture(
|
||||
image: vk::Image,
|
||||
width: u32,
|
||||
height: u32,
|
||||
mip_levels: u32,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let allocate_info = vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(command_pool)
|
||||
@@ -407,7 +748,7 @@ fn upload_static_texture(
|
||||
result,
|
||||
});
|
||||
}
|
||||
let range = color_subresource_range();
|
||||
let range = color_subresource_range(mip_levels);
|
||||
let to_transfer = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
@@ -415,19 +756,34 @@ fn upload_static_texture(
|
||||
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.image(image)
|
||||
.subresource_range(range);
|
||||
let region = vk::BufferImageCopy::default()
|
||||
let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0));
|
||||
let mut buffer_offset = 0_u64;
|
||||
let mut mip_width = width;
|
||||
let mut mip_height = height;
|
||||
for mip_level in 0..mip_levels {
|
||||
let mip_bytes = u64::from(mip_width)
|
||||
.saturating_mul(u64::from(mip_height))
|
||||
.saturating_mul(4);
|
||||
regions.push(
|
||||
vk::BufferImageCopy::default()
|
||||
.buffer_offset(buffer_offset)
|
||||
.image_subresource(
|
||||
vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.mip_level(0)
|
||||
.mip_level(mip_level)
|
||||
.base_array_layer(0)
|
||||
.layer_count(1),
|
||||
)
|
||||
.image_extent(vk::Extent3D {
|
||||
width,
|
||||
height,
|
||||
width: mip_width,
|
||||
height: mip_height,
|
||||
depth: 1,
|
||||
});
|
||||
}),
|
||||
);
|
||||
buffer_offset = buffer_offset.saturating_add(mip_bytes);
|
||||
mip_width = (mip_width / 2).max(1);
|
||||
mip_height = (mip_height / 2).max(1);
|
||||
}
|
||||
let to_sampled = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
@@ -451,7 +807,7 @@ fn upload_static_texture(
|
||||
staging_buffer,
|
||||
image,
|
||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
||||
&[region],
|
||||
®ions,
|
||||
);
|
||||
device.device().cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
@@ -500,6 +856,246 @@ fn upload_static_texture(
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces the contents of an already sampled texture without changing its
|
||||
/// image view or descriptor set. The caller must wait for the renderer's
|
||||
/// submitted work before invoking this function.
|
||||
pub(super) fn update_static_texture_image(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
image: &VulkanAllocatedImage,
|
||||
texture: &VulkanStaticTexture,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
texture
|
||||
.validate()
|
||||
.map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
|
||||
let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture);
|
||||
if texture.width != image.width
|
||||
|| texture.height != image.height
|
||||
|| mip_levels != image.mip_levels
|
||||
{
|
||||
return Err(VulkanSmokeRendererError::InvalidStaticTexture {
|
||||
context: "dynamic texture extent or mip count differs from allocation",
|
||||
});
|
||||
}
|
||||
let staging = create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&mip_bytes,
|
||||
vk::BufferUsageFlags::TRANSFER_SRC,
|
||||
"dynamic texture staging buffer",
|
||||
)?;
|
||||
let allocate_info = vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(command_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(1);
|
||||
// SAFETY: The command pool is live and owned by the current logical device.
|
||||
let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) }
|
||||
.map_err(|result| {
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAllocateCommandBuffers(dynamic texture upload)",
|
||||
result,
|
||||
}
|
||||
})?[0];
|
||||
let begin =
|
||||
vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
|
||||
// SAFETY: The command buffer is freshly allocated from the current pool.
|
||||
if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } {
|
||||
// SAFETY: The command buffer belongs to this pool and is released on failure.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &[command_buffer]);
|
||||
};
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkBeginCommandBuffer(dynamic texture upload)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
let range = color_subresource_range(mip_levels);
|
||||
let to_transfer = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.src_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.image(image.image)
|
||||
.subresource_range(range);
|
||||
let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0));
|
||||
let mut buffer_offset = 0_u64;
|
||||
let mut mip_width = texture.width;
|
||||
let mut mip_height = texture.height;
|
||||
for mip_level in 0..mip_levels {
|
||||
let mip_bytes = u64::from(mip_width)
|
||||
.saturating_mul(u64::from(mip_height))
|
||||
.saturating_mul(4);
|
||||
regions.push(
|
||||
vk::BufferImageCopy::default()
|
||||
.buffer_offset(buffer_offset)
|
||||
.image_subresource(
|
||||
vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.mip_level(mip_level)
|
||||
.base_array_layer(0)
|
||||
.layer_count(1),
|
||||
)
|
||||
.image_extent(vk::Extent3D {
|
||||
width: mip_width,
|
||||
height: mip_height,
|
||||
depth: 1,
|
||||
}),
|
||||
);
|
||||
buffer_offset = buffer_offset.saturating_add(mip_bytes);
|
||||
mip_width = (mip_width / 2).max(1);
|
||||
mip_height = (mip_height / 2).max(1);
|
||||
}
|
||||
let to_sampled = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.image(image.image)
|
||||
.subresource_range(range);
|
||||
// SAFETY: The caller has made all prior shader reads idle, and the image
|
||||
// remains exclusively owned by this graphics queue.
|
||||
unsafe {
|
||||
device.device().cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_transfer],
|
||||
);
|
||||
device.device().cmd_copy_buffer_to_image(
|
||||
command_buffer,
|
||||
staging.buffer,
|
||||
image.image,
|
||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
||||
®ions,
|
||||
);
|
||||
device.device().cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_sampled],
|
||||
);
|
||||
}
|
||||
// SAFETY: Command recording is complete on the current command buffer.
|
||||
if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } {
|
||||
// SAFETY: The command buffer belongs to this pool and is released on failure.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &[command_buffer]);
|
||||
};
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkEndCommandBuffer(dynamic texture upload)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
let command_buffers = [command_buffer];
|
||||
let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)];
|
||||
// SAFETY: The graphics queue and submitted command buffer remain live
|
||||
// until this synchronous wait completes.
|
||||
let submit_result = unsafe {
|
||||
device
|
||||
.device()
|
||||
.queue_submit(device.graphics_queue(), &submit, vk::Fence::null())
|
||||
};
|
||||
let result = submit_result
|
||||
.and_then(|()| unsafe { device.device().queue_wait_idle(device.graphics_queue()) });
|
||||
// SAFETY: Submission completed or failed synchronously.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &[command_buffer]);
|
||||
}
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
result.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkQueueSubmit/WaitIdle(dynamic texture upload)",
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds an RGBA8 mip chain on the CPU so texture upload does not depend on
|
||||
/// optional linear-blit support for the device's UNORM format.
|
||||
fn build_static_texture_mip_chain(texture: &VulkanStaticTexture) -> (Vec<u8>, u32) {
|
||||
if !texture.mip_levels.is_empty() {
|
||||
let mut bytes = Vec::new();
|
||||
for mip in &texture.mip_levels {
|
||||
bytes.extend_from_slice(&mip.rgba8);
|
||||
}
|
||||
return (
|
||||
bytes,
|
||||
u32::try_from(texture.mip_levels.len()).unwrap_or(u32::MAX),
|
||||
);
|
||||
}
|
||||
let mut bytes = texture.rgba8.clone();
|
||||
let mut level = texture.rgba8.clone();
|
||||
let mut width = texture.width;
|
||||
let mut height = texture.height;
|
||||
let mut mip_levels = 1_u32;
|
||||
|
||||
while width > 1 || height > 1 {
|
||||
let next_width = (width / 2).max(1);
|
||||
let next_height = (height / 2).max(1);
|
||||
let mut next = vec![
|
||||
0_u8;
|
||||
usize::try_from(next_width)
|
||||
.unwrap_or(0)
|
||||
.saturating_mul(usize::try_from(next_height).unwrap_or(0))
|
||||
.saturating_mul(4)
|
||||
];
|
||||
for y in 0..next_height {
|
||||
for x in 0..next_width {
|
||||
let dst = (usize::try_from(y).unwrap_or(0)
|
||||
* usize::try_from(next_width).unwrap_or(0)
|
||||
+ usize::try_from(x).unwrap_or(0))
|
||||
* 4;
|
||||
let x0 = x.saturating_mul(2).min(width.saturating_sub(1));
|
||||
let y0 = y.saturating_mul(2).min(height.saturating_sub(1));
|
||||
let x1 = x0.saturating_add(1).min(width.saturating_sub(1));
|
||||
let y1 = y0.saturating_add(1).min(height.saturating_sub(1));
|
||||
let source_width = usize::try_from(width).unwrap_or(0);
|
||||
let samples = [
|
||||
(usize::try_from(y0).unwrap_or(0) * source_width
|
||||
+ usize::try_from(x0).unwrap_or(0))
|
||||
* 4,
|
||||
(usize::try_from(y0).unwrap_or(0) * source_width
|
||||
+ usize::try_from(x1).unwrap_or(0))
|
||||
* 4,
|
||||
(usize::try_from(y1).unwrap_or(0) * source_width
|
||||
+ usize::try_from(x0).unwrap_or(0))
|
||||
* 4,
|
||||
(usize::try_from(y1).unwrap_or(0) * source_width
|
||||
+ usize::try_from(x1).unwrap_or(0))
|
||||
* 4,
|
||||
];
|
||||
for channel in 0..4 {
|
||||
let sum: u16 = samples
|
||||
.iter()
|
||||
.map(|&offset| u16::from(level[offset + channel]))
|
||||
.sum();
|
||||
next[dst + channel] = u8::try_from((sum + 2) / 4).unwrap_or(u8::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes.extend_from_slice(&next);
|
||||
level = next;
|
||||
width = next_width;
|
||||
height = next_height;
|
||||
mip_levels = mip_levels.saturating_add(1);
|
||||
}
|
||||
(bytes, mip_levels)
|
||||
}
|
||||
|
||||
pub(super) fn destroy_allocated_image(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
image: &VulkanAllocatedImage,
|
||||
@@ -624,36 +1220,24 @@ pub(super) fn create_frame_sync(
|
||||
let mut sync = Vec::with_capacity(2);
|
||||
for _ in 0..2 {
|
||||
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
|
||||
let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) }
|
||||
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSemaphore(image_available)",
|
||||
result: error,
|
||||
})?;
|
||||
let render_finished = {
|
||||
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
|
||||
let image_available =
|
||||
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
|
||||
Ok(render_finished) => render_finished,
|
||||
Ok(image_available) => image_available,
|
||||
Err(error) => {
|
||||
destroy_frame_sync_objects(device, &sync);
|
||||
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_semaphore(image_available, None) };
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSemaphore(render_finished)",
|
||||
context: "vkCreateSemaphore(image_available)",
|
||||
result: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
// SAFETY: The fence belongs to this live logical device and is destroyed at teardown.
|
||||
let fence = match unsafe { device.device().create_fence(&fence_info, None) } {
|
||||
Ok(fence) => fence,
|
||||
Err(error) => {
|
||||
destroy_frame_sync_objects(device, &sync);
|
||||
// SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure.
|
||||
unsafe {
|
||||
device.device().destroy_semaphore(image_available, None);
|
||||
device.device().destroy_semaphore(render_finished, None);
|
||||
}
|
||||
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_semaphore(image_available, None) };
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateFence",
|
||||
result: error,
|
||||
@@ -662,7 +1246,6 @@ pub(super) fn create_frame_sync(
|
||||
};
|
||||
sync.push(VulkanFrameSync {
|
||||
image_available,
|
||||
render_finished,
|
||||
fence,
|
||||
});
|
||||
}
|
||||
@@ -676,9 +1259,6 @@ fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanF
|
||||
device
|
||||
.device()
|
||||
.destroy_semaphore(frame_sync.image_available, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_semaphore(frame_sync.render_finished, None);
|
||||
device.device().destroy_fence(frame_sync.fence, None);
|
||||
}
|
||||
}
|
||||
@@ -738,11 +1318,11 @@ pub(super) fn readback_buffer_bytes(
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
|
||||
pub(super) fn color_subresource_range(level_count: u32) -> vk::ImageSubresourceRange {
|
||||
vk::ImageSubresourceRange::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.base_mip_level(0)
|
||||
.level_count(1)
|
||||
.level_count(level_count)
|
||||
.base_array_layer(0)
|
||||
.layer_count(1)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,8 +48,20 @@ pub struct VulkanStaticVertex {
|
||||
pub position: [f32; 3],
|
||||
/// Linear RGB vertex color.
|
||||
pub color: [f32; 3],
|
||||
/// Unit vertex normal in world/object coordinates.
|
||||
pub normal: [f32; 3],
|
||||
/// Texture coordinate consumed by the static material bridge.
|
||||
pub uv: [f32; 2],
|
||||
/// Repeating detail texture coordinate from terrain type-18 UV data.
|
||||
///
|
||||
/// Non-terrain geometry supplies `[0.0; 2]`; its neutral detail texture
|
||||
/// keeps the additional stage mathematically transparent.
|
||||
pub detail_uv: [f32; 2],
|
||||
/// Interpolated terrain overlay mask from type-14 data.
|
||||
///
|
||||
/// Non-terrain geometry supplies `0.0`, disabling the transparent overlay
|
||||
/// slot while retaining one vertex contract for every static draw.
|
||||
pub overlay_alpha: f32,
|
||||
}
|
||||
|
||||
/// A static geometry camera represented in the shader's matrix memory order.
|
||||
@@ -71,7 +83,9 @@ impl VulkanStaticCamera {
|
||||
projection: LegacyD3d7Projection,
|
||||
) -> Option<Self> {
|
||||
let view = transform.try_direct3d7_view_row_major()?;
|
||||
let projection = projection.try_direct3d7_projection_row_major()?;
|
||||
let mut projection = projection.try_direct3d7_projection_row_major()?;
|
||||
// D3D7's top-down viewport and Vulkan's positive-height viewport invert NDC Y.
|
||||
projection[5] = -projection[5];
|
||||
Self::from_row_major_view_projection(view, projection)
|
||||
}
|
||||
|
||||
@@ -108,6 +122,242 @@ impl Default for VulkanStaticCamera {
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of directional records carried by [`VulkanFrameUniforms`].
|
||||
pub const VULKAN_DIRECTIONAL_LIGHT_COUNT: usize = 4;
|
||||
|
||||
/// One directional light in the frame lighting contract.
|
||||
///
|
||||
/// The direction is the vector from the light toward the surface. The
|
||||
/// coefficients are retained in the same three-component record as the
|
||||
/// native point callback so frame construction can copy the original light
|
||||
/// table without a lossy conversion.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VulkanDirectionalLight {
|
||||
/// Direction from the light toward the surface.
|
||||
pub direction: [f32; 3],
|
||||
/// Light RGB multiplier.
|
||||
pub rgb: [f32; 3],
|
||||
/// Native light coefficients. Directional callbacks normally use the
|
||||
/// neutral `[0, 0, 1]` record; the field keeps the shared light ABI.
|
||||
pub coefficients: [f32; 3],
|
||||
/// Whether the light participates in this frame.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl Default for VulkanDirectionalLight {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
direction: [0.0, 0.0, 1.0],
|
||||
rgb: [0.0; 3],
|
||||
coefficients: [0.0, 0.0, 1.0],
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One point light in the frame lighting contract.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VulkanPointLight {
|
||||
/// World-space position.
|
||||
pub position: [f32; 3],
|
||||
/// Light RGB multiplier.
|
||||
pub rgb: [f32; 3],
|
||||
/// Radius used by native normalized distance attenuation.
|
||||
pub range: f32,
|
||||
/// Native attenuation coefficients `a0`, `a1`, and `a2`.
|
||||
pub coefficients: [f32; 3],
|
||||
/// Whether the light participates in this frame.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl Default for VulkanPointLight {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
position: [0.0; 3],
|
||||
rgb: [0.0; 3],
|
||||
range: 0.0,
|
||||
// A neutral record is explicit. In particular, this is the
|
||||
// proven native environment contract and avoids an accidental
|
||||
// zero polynomial when a caller enables the point slot.
|
||||
coefficients: [0.0, 0.0, 1.0],
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of bytes occupied by one frame-uniform slot.
|
||||
///
|
||||
/// The block is deliberately made entirely from `mat4`/`vec4`-sized records
|
||||
/// so its CPU representation follows Vulkan's `std140` alignment rules on
|
||||
/// every device. Four directional lights and one point light fit in a small
|
||||
/// dynamic UBO while the push-constant range remains available for material
|
||||
/// state.
|
||||
pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368;
|
||||
|
||||
/// Bytes occupied by one material's base and overlay specular records.
|
||||
///
|
||||
/// Each record is one `vec4`: RGB followed by the native integer power stored
|
||||
/// as an IEEE-754 float for direct shader use. The allocation stride is
|
||||
/// rounded up to the device's `minStorageBufferOffsetAlignment`.
|
||||
pub const VULKAN_MATERIAL_SPECULAR_RECORD_BYTES: usize = 2 * 4 * std::mem::size_of::<f32>();
|
||||
|
||||
/// Per-frame data shared by the vertex and fragment stages.
|
||||
///
|
||||
/// `clip_from_world` is row-major D3D-style storage for the same reason as
|
||||
/// [`VulkanStaticCamera`]. A directional light direction points from the
|
||||
/// light toward the surface; the shader negates it when taking the
|
||||
/// surface-normal dot product. Fog distances are disabled when
|
||||
/// `fog_end <= fog_start`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VulkanFrameUniforms {
|
||||
/// Row-major clip-from-world transform.
|
||||
pub clip_from_world: [f32; 16],
|
||||
/// Up to four native directional/celestial lights.
|
||||
pub directional_lights: [VulkanDirectionalLight; VULKAN_DIRECTIONAL_LIGHT_COUNT],
|
||||
/// Native point light slot.
|
||||
pub point_light: VulkanPointLight,
|
||||
/// Per-channel lighting floor applied before native range compression.
|
||||
pub lighting_floor: [f32; 3],
|
||||
/// Linear fog RGB color.
|
||||
pub fog_color: [f32; 3],
|
||||
/// Fog start distance.
|
||||
pub fog_start: f32,
|
||||
/// Fog end distance.
|
||||
pub fog_end: f32,
|
||||
/// World-space camera position used for fog distance.
|
||||
pub camera_position: [f32; 3],
|
||||
}
|
||||
|
||||
impl VulkanFrameUniforms {
|
||||
/// Builds neutral frame state from a static camera.
|
||||
#[must_use]
|
||||
pub fn from_camera(camera: VulkanStaticCamera) -> Self {
|
||||
let mut directional_lights =
|
||||
[VulkanDirectionalLight::default(); VULKAN_DIRECTIONAL_LIGHT_COUNT];
|
||||
directional_lights[0] = VulkanDirectionalLight {
|
||||
direction: [0.35, 0.45, 0.82],
|
||||
rgb: [1.0; 3],
|
||||
coefficients: [0.0, 0.0, 1.0],
|
||||
active: true,
|
||||
};
|
||||
Self {
|
||||
clip_from_world: camera.clip_from_world,
|
||||
directional_lights,
|
||||
point_light: VulkanPointLight::default(),
|
||||
lighting_floor: [0.30; 3],
|
||||
fog_color: [0.0; 3],
|
||||
fog_start: f32::MAX,
|
||||
fog_end: f32::MAX,
|
||||
camera_position: [0.0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether all submitted frame values are finite.
|
||||
#[must_use]
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.clip_from_world
|
||||
.into_iter()
|
||||
.chain(self.directional_lights.into_iter().flat_map(|light| {
|
||||
light
|
||||
.direction
|
||||
.into_iter()
|
||||
.chain(light.rgb)
|
||||
.chain(light.coefficients)
|
||||
.chain([if light.active { 1.0 } else { 0.0 }])
|
||||
}))
|
||||
.chain(self.point_light.position)
|
||||
.chain(self.point_light.rgb)
|
||||
.chain([self.point_light.range])
|
||||
.chain(self.point_light.coefficients)
|
||||
.chain([if self.point_light.active { 1.0 } else { 0.0 }])
|
||||
.chain(self.lighting_floor)
|
||||
.chain(self.fog_color)
|
||||
.chain([self.fog_start, self.fog_end])
|
||||
.chain(self.camera_position)
|
||||
.all(f32::is_finite)
|
||||
}
|
||||
|
||||
/// Serializes one frame slot in the exact `std140` member order.
|
||||
#[must_use]
|
||||
pub fn to_ne_bytes(self) -> [u8; VULKAN_FRAME_UNIFORM_BYTES] {
|
||||
let mut values = [0.0_f32; VULKAN_FRAME_UNIFORM_BYTES / 4];
|
||||
values[..16].copy_from_slice(&self.clip_from_world);
|
||||
for (index, light) in self.directional_lights.into_iter().enumerate() {
|
||||
let direction_offset = 16 + index * 4;
|
||||
values[direction_offset..direction_offset + 4].copy_from_slice(&[
|
||||
light.direction[0],
|
||||
light.direction[1],
|
||||
light.direction[2],
|
||||
if light.active { 1.0 } else { 0.0 },
|
||||
]);
|
||||
let rgb_offset = 32 + index * 4;
|
||||
values[rgb_offset..rgb_offset + 4].copy_from_slice(&[
|
||||
light.rgb[0],
|
||||
light.rgb[1],
|
||||
light.rgb[2],
|
||||
if light.active { 1.0 } else { 0.0 },
|
||||
]);
|
||||
let coefficient_offset = 48 + index * 4;
|
||||
values[coefficient_offset..coefficient_offset + 4].copy_from_slice(&[
|
||||
light.coefficients[0],
|
||||
light.coefficients[1],
|
||||
light.coefficients[2],
|
||||
if light.active { 1.0 } else { 0.0 },
|
||||
]);
|
||||
}
|
||||
values[64..68].copy_from_slice(&[
|
||||
self.point_light.position[0],
|
||||
self.point_light.position[1],
|
||||
self.point_light.position[2],
|
||||
self.point_light.range,
|
||||
]);
|
||||
values[68..72].copy_from_slice(&[
|
||||
self.point_light.rgb[0],
|
||||
self.point_light.rgb[1],
|
||||
self.point_light.rgb[2],
|
||||
if self.point_light.active { 1.0 } else { 0.0 },
|
||||
]);
|
||||
values[72..76].copy_from_slice(&[
|
||||
self.point_light.coefficients[0],
|
||||
self.point_light.coefficients[1],
|
||||
self.point_light.coefficients[2],
|
||||
if self.point_light.active { 1.0 } else { 0.0 },
|
||||
]);
|
||||
values[76..80].copy_from_slice(&[
|
||||
self.lighting_floor[0],
|
||||
self.lighting_floor[1],
|
||||
self.lighting_floor[2],
|
||||
0.0,
|
||||
]);
|
||||
values[80..84].copy_from_slice(&[
|
||||
self.fog_color[0],
|
||||
self.fog_color[1],
|
||||
self.fog_color[2],
|
||||
0.0,
|
||||
]);
|
||||
values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]);
|
||||
values[88..92].copy_from_slice(&[
|
||||
self.camera_position[0],
|
||||
self.camera_position[1],
|
||||
self.camera_position[2],
|
||||
0.0,
|
||||
]);
|
||||
let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES];
|
||||
for (index, value) in values.into_iter().enumerate() {
|
||||
let offset = index * std::mem::size_of::<f32>();
|
||||
bytes[offset..offset + std::mem::size_of::<f32>()]
|
||||
.copy_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VulkanFrameUniforms {
|
||||
fn default() -> Self {
|
||||
Self::from_camera(VulkanStaticCamera::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
|
||||
let mut result = [0.0; 16];
|
||||
for row in 0..4 {
|
||||
@@ -131,6 +381,23 @@ pub struct VulkanStaticMesh {
|
||||
pub draw_ranges: Vec<VulkanStaticDrawRange>,
|
||||
}
|
||||
|
||||
/// Geometry-only changes applied to one retained draw range.
|
||||
///
|
||||
/// The renderer owns the material, blend/depth state, active count changes,
|
||||
/// and queue classification for every range. A frame builder uses this
|
||||
/// record only when a variable-size geometry stream (such as projected
|
||||
/// shadows) moves its index segment or changes its triangle count.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanDynamicDrawRange {
|
||||
/// Stable draw-range handle returned by the initial mesh layout.
|
||||
pub range_index: usize,
|
||||
/// First index in the current shared index upload.
|
||||
pub first_index: u32,
|
||||
/// Active triangle-list index count. Zero disables this range for the
|
||||
/// current frame without allocating a zero-sized Vulkan buffer.
|
||||
pub index_count: u32,
|
||||
}
|
||||
|
||||
/// One indexed triangle-list draw retained from an original mesh batch.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanStaticDrawRange {
|
||||
@@ -140,6 +407,12 @@ pub struct VulkanStaticDrawRange {
|
||||
pub index_count: u32,
|
||||
/// Original positional `Batch20.material_index` selector.
|
||||
pub material_index: u16,
|
||||
/// Original positional `Batch20.lightmap_index` selector.
|
||||
pub lightmap_index: u8,
|
||||
/// Original MSH Batch20 flag word used by the native transparent queue.
|
||||
/// Bits `0x100` and `0x008` opt a range into that queue independently of
|
||||
/// the material blend category.
|
||||
pub batch_flags: u16,
|
||||
/// Backend-neutral fixed-function state for this source range.
|
||||
pub pipeline_state: LegacyPipelineState,
|
||||
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
|
||||
@@ -147,6 +420,13 @@ pub struct VulkanStaticDrawRange {
|
||||
}
|
||||
|
||||
impl VulkanStaticDrawRange {
|
||||
/// The value installed by the native renderer before a material-specific
|
||||
/// alpha reference is written. The native Ngi32 initialiser stores one
|
||||
/// in `D3DRS_ALPHAREF`; callers that do not have a material-specific value
|
||||
/// should pass this constant explicitly. Zero remains a valid explicit
|
||||
/// alpha reference.
|
||||
pub const NATIVE_DEFAULT_ALPHA_TEST_REFERENCE: u8 = 1;
|
||||
|
||||
/// Returns the canonical key used for Vulkan pipeline selection.
|
||||
#[must_use]
|
||||
pub fn pipeline_key(self) -> PipelineKey {
|
||||
@@ -165,12 +445,75 @@ impl VulkanStaticDrawRange {
|
||||
}
|
||||
|
||||
/// One diffuse material texture keyed by an original MSH batch selector.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VulkanStaticMaterial {
|
||||
/// Positional material selector used by one or more source batches.
|
||||
pub material_index: u16,
|
||||
/// Decoded RGBA8 diffuse texture for this selector.
|
||||
pub texture: VulkanStaticTexture,
|
||||
/// Phase parameters 4..6, normalized directional light RGB coefficient.
|
||||
pub directional_rgb: [f32; 3],
|
||||
/// Phase parameters 0..2, normalized additive RGB coefficient.
|
||||
pub additive_rgb: [f32; 3],
|
||||
/// Normalized atlas transform for base texture `[x, y, w, h]`.
|
||||
pub uv_transform: [f32; 4],
|
||||
/// Optional Land2 detail texture paired with the base texture.
|
||||
pub detail_texture: Option<VulkanStaticTexture>,
|
||||
/// Optional Land1 overlay texture selected by the face tag high byte.
|
||||
pub overlay_texture: Option<VulkanStaticTexture>,
|
||||
/// Optional Land2 detail texture paired with the overlay texture.
|
||||
pub overlay_detail_texture: Option<VulkanStaticTexture>,
|
||||
/// MAT0 phase diffuse alpha, normalized from the legacy 0..=255 byte.
|
||||
pub diffuse_alpha: f32,
|
||||
/// MAT0 phase alpha for the optional overlay material.
|
||||
pub overlay_diffuse_alpha: Option<f32>,
|
||||
/// Phase parameters 4..6 for the optional overlay material.
|
||||
pub overlay_directional_rgb: Option<[f32; 3]>,
|
||||
/// Phase parameters 0..2 for the optional overlay material.
|
||||
pub overlay_additive_rgb: Option<[f32; 3]>,
|
||||
/// Phase parameters 8..10, normalized base specular RGB coefficient.
|
||||
///
|
||||
/// Specular data is kept in a storage buffer instead of push constants so
|
||||
/// the vertex stage can evaluate the native Gouraud highlight while the
|
||||
/// material's other dynamic values retain their existing ABI.
|
||||
pub specular_rgb: [f32; 3],
|
||||
/// Phase parameter 16 for the base material, carried as the native byte.
|
||||
pub specular_power: u8,
|
||||
/// Phase parameters 8..10 for the optional overlay material.
|
||||
pub overlay_specular_rgb: Option<[f32; 3]>,
|
||||
/// Phase parameter 16 for the optional overlay material.
|
||||
pub overlay_specular_power: Option<u8>,
|
||||
/// Normalized atlas transform for the optional detail texture.
|
||||
pub detail_uv_transform: [f32; 4],
|
||||
/// Normalized atlas transform for the optional overlay texture.
|
||||
pub overlay_uv_transform: Option<[f32; 4]>,
|
||||
/// Normalized atlas transform for the optional overlay detail texture.
|
||||
pub overlay_detail_uv_transform: Option<[f32; 4]>,
|
||||
/// Environment layers bypass terrain lighting and fog while retaining the
|
||||
/// same descriptor/pipeline path.
|
||||
pub unlit: bool,
|
||||
/// Native sky mode 4: blend base and secondary textures by the secondary
|
||||
/// texture alpha before multiplying by the vertex diffuse color.
|
||||
pub sky_nebula_stars: bool,
|
||||
/// Native terrain mode 3: combine base and secondary lightmap textures
|
||||
/// without the regular terrain `2 * TEX0 * TEX1` combiner or NdotL.
|
||||
pub lightmap_mode: bool,
|
||||
/// Push material mode bit 4 and map this sky layer to the Vulkan far
|
||||
/// plane without changing its world-space vertices or UVs. This is a
|
||||
/// renderer projection choice; it is not a claim about the original D3D
|
||||
/// depth-bit encoding.
|
||||
pub sky_far_depth: bool,
|
||||
}
|
||||
|
||||
/// One authored RGBA8 mip level accepted by the Vulkan texture upload path.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanStaticTextureMip {
|
||||
/// Mip width in texels.
|
||||
pub width: u32,
|
||||
/// Mip height in texels.
|
||||
pub height: u32,
|
||||
/// Row-major RGBA8 pixels.
|
||||
pub rgba8: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
|
||||
@@ -182,6 +525,10 @@ pub struct VulkanStaticTexture {
|
||||
pub height: u32,
|
||||
/// Row-major RGBA8 pixels.
|
||||
pub rgba8: Vec<u8>,
|
||||
/// Authored mip levels, including mip zero, when available.
|
||||
///
|
||||
/// An empty vector requests deterministic CPU generation from `rgba8`.
|
||||
pub mip_levels: Vec<VulkanStaticTextureMip>,
|
||||
}
|
||||
|
||||
impl VulkanStaticTexture {
|
||||
@@ -200,7 +547,46 @@ impl VulkanStaticTexture {
|
||||
Some(expected) if expected != self.rgba8.len() => {
|
||||
Err("static texture rgba8 byte count does not match extent")
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
Some(_) => {
|
||||
if self.mip_levels.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// TEXM files commonly stop at the authored 4x4 level. Vulkan
|
||||
// permits a partial mip chain; only the supplied level
|
||||
// extents and bytes need validation.
|
||||
if self.mip_levels.len()
|
||||
> usize::try_from(self.width.max(self.height))
|
||||
.unwrap_or(usize::MAX)
|
||||
.ilog2() as usize
|
||||
+ 1
|
||||
{
|
||||
return Err("static texture authored mip count exceeds extent");
|
||||
}
|
||||
let mut width = self.width;
|
||||
let mut height = self.height;
|
||||
for (level, mip) in self.mip_levels.iter().enumerate() {
|
||||
if mip.width != width || mip.height != height {
|
||||
return Err("static texture authored mip extent does not match level");
|
||||
}
|
||||
let expected = usize::try_from(width)
|
||||
.ok()
|
||||
.and_then(|width| {
|
||||
usize::try_from(height)
|
||||
.ok()
|
||||
.and_then(|height| width.checked_mul(height))
|
||||
})
|
||||
.and_then(|pixels| pixels.checked_mul(4));
|
||||
if expected != Some(mip.rgba8.len()) {
|
||||
return Err("static texture authored mip byte count does not match extent");
|
||||
}
|
||||
if level == 0 && mip.rgba8 != self.rgba8 {
|
||||
return Err("static texture authored mip zero does not match rgba8");
|
||||
}
|
||||
width = (width / 2).max(1);
|
||||
height = (height / 2).max(1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +602,60 @@ pub(super) fn resolve_draw_texture_indices(
|
||||
) -> Result<Vec<usize>, &'static str> {
|
||||
for (index, material) in materials.iter().enumerate() {
|
||||
material.texture.validate()?;
|
||||
if let Some(texture) = &material.detail_texture {
|
||||
texture.validate()?;
|
||||
}
|
||||
if let Some(texture) = &material.overlay_texture {
|
||||
texture.validate()?;
|
||||
}
|
||||
if let Some(texture) = &material.overlay_detail_texture {
|
||||
texture.validate()?;
|
||||
}
|
||||
if !material.diffuse_alpha.is_finite() || !(0.0..=1.0).contains(&material.diffuse_alpha) {
|
||||
return Err("static material diffuse alpha must be finite and 0..1");
|
||||
}
|
||||
if material
|
||||
.directional_rgb
|
||||
.into_iter()
|
||||
.chain(material.additive_rgb)
|
||||
.chain(material.specular_rgb)
|
||||
.chain(material.overlay_specular_rgb.into_iter().flatten())
|
||||
.any(|value| !value.is_finite() || value < 0.0)
|
||||
{
|
||||
return Err("static material lighting and specular RGB parameters must be finite and non-negative");
|
||||
}
|
||||
if material
|
||||
.uv_transform
|
||||
.into_iter()
|
||||
.chain(material.detail_uv_transform)
|
||||
.any(|value| !value.is_finite())
|
||||
{
|
||||
return Err("static material UV transforms must be finite");
|
||||
}
|
||||
if material
|
||||
.overlay_diffuse_alpha
|
||||
.is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha))
|
||||
{
|
||||
return Err("static material overlay diffuse alpha must be finite and 0..1");
|
||||
}
|
||||
if material
|
||||
.overlay_directional_rgb
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(material.overlay_additive_rgb.into_iter().flatten())
|
||||
.any(|value| !value.is_finite() || value < 0.0)
|
||||
{
|
||||
return Err("static material overlay RGB parameters must be finite and non-negative");
|
||||
}
|
||||
if material
|
||||
.overlay_uv_transform
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(material.overlay_detail_uv_transform.into_iter().flatten())
|
||||
.any(|value| !value.is_finite())
|
||||
{
|
||||
return Err("static material overlay UV transforms must be finite");
|
||||
}
|
||||
if materials[..index]
|
||||
.iter()
|
||||
.any(|previous| previous.material_index == material.material_index)
|
||||
@@ -247,17 +687,26 @@ impl VulkanStaticMesh {
|
||||
VulkanStaticVertex {
|
||||
position: [0.0, -0.55, 0.0],
|
||||
color: [1.0, 0.2, 0.2],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [0.5, 0.0],
|
||||
detail_uv: [0.0, 0.0],
|
||||
overlay_alpha: 0.0,
|
||||
},
|
||||
VulkanStaticVertex {
|
||||
position: [0.55, 0.55, 0.0],
|
||||
color: [0.2, 1.0, 0.2],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [1.0, 1.0],
|
||||
detail_uv: [0.0, 0.0],
|
||||
overlay_alpha: 0.0,
|
||||
},
|
||||
VulkanStaticVertex {
|
||||
position: [-0.55, 0.55, 0.0],
|
||||
color: [0.2, 0.4, 1.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
uv: [0.0, 1.0],
|
||||
detail_uv: [0.0, 0.0],
|
||||
overlay_alpha: 0.0,
|
||||
},
|
||||
],
|
||||
indices: vec![0, 1, 2],
|
||||
@@ -265,6 +714,8 @@ impl VulkanStaticMesh {
|
||||
first_index: 0,
|
||||
index_count: 3,
|
||||
material_index: 0,
|
||||
lightmap_index: u8::MAX,
|
||||
batch_flags: 0,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
}],
|
||||
@@ -275,6 +726,22 @@ impl VulkanStaticMesh {
|
||||
if self.vertices.is_empty() {
|
||||
return Err("static mesh has no vertices");
|
||||
}
|
||||
if self.vertices.iter().any(|vertex| {
|
||||
!vertex
|
||||
.position
|
||||
.iter()
|
||||
.chain(vertex.color.iter())
|
||||
.chain(vertex.normal.iter())
|
||||
.chain(vertex.uv.iter())
|
||||
.chain(vertex.detail_uv.iter())
|
||||
.chain(std::iter::once(&vertex.overlay_alpha))
|
||||
.all(|value| value.is_finite())
|
||||
|| !(0.0..=1.0).contains(&vertex.overlay_alpha)
|
||||
}) {
|
||||
return Err(
|
||||
"static mesh vertex attributes must be finite and overlay alpha must be 0..1",
|
||||
);
|
||||
}
|
||||
if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
|
||||
return Err("static mesh indices must contain complete triangles");
|
||||
}
|
||||
@@ -311,6 +778,53 @@ impl VulkanStaticMesh {
|
||||
mod static_mesh_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_uniforms_use_std140_sized_slots_and_preserve_dynamic_values() {
|
||||
let frame = VulkanFrameUniforms {
|
||||
clip_from_world: std::array::from_fn(|index| index as f32),
|
||||
directional_lights: [
|
||||
VulkanDirectionalLight {
|
||||
direction: [1.0, 2.0, 3.0],
|
||||
rgb: [4.0, 5.0, 6.0],
|
||||
coefficients: [7.0, 8.0, 9.0],
|
||||
active: true,
|
||||
},
|
||||
VulkanDirectionalLight::default(),
|
||||
VulkanDirectionalLight::default(),
|
||||
VulkanDirectionalLight::default(),
|
||||
],
|
||||
point_light: VulkanPointLight {
|
||||
position: [28.0, 29.0, 30.0],
|
||||
rgb: [31.0, 32.0, 33.0],
|
||||
range: 34.0,
|
||||
coefficients: [35.0, 36.0, 37.0],
|
||||
active: true,
|
||||
},
|
||||
lighting_floor: [7.0, 8.0, 9.0],
|
||||
fog_color: [10.0, 11.0, 12.0],
|
||||
fog_start: 13.0,
|
||||
fog_end: 14.0,
|
||||
camera_position: [15.0, 16.0, 17.0],
|
||||
};
|
||||
let bytes = frame.to_ne_bytes();
|
||||
assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES);
|
||||
let read =
|
||||
|offset: usize| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().expect("f32"));
|
||||
assert_eq!(read(0), 0.0);
|
||||
assert_eq!(read(60), 15.0);
|
||||
assert_eq!(read(64), 1.0);
|
||||
assert_eq!(read(76), 1.0);
|
||||
assert_eq!(read(128), 4.0);
|
||||
assert_eq!(read(192), 7.0);
|
||||
assert_eq!(read(256), 28.0);
|
||||
assert_eq!(read(272), 31.0);
|
||||
assert_eq!(read(288), 35.0);
|
||||
assert_eq!(read(304), 7.0);
|
||||
assert_eq!(read(320), 10.0);
|
||||
assert_eq!(read(336), 13.0);
|
||||
assert_eq!(read(352), 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_triangle_is_valid_complete_geometry() {
|
||||
let mesh = VulkanStaticMesh::smoke_triangle();
|
||||
@@ -357,6 +871,54 @@ mod static_mesh_tests {
|
||||
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_camera_maps_world_up_to_top_of_positive_height_vulkan_viewport() {
|
||||
let transform = RawCameraTransform {
|
||||
words: [
|
||||
1.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
1.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
1.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
1.0_f32.to_bits(),
|
||||
],
|
||||
};
|
||||
let projection = LegacyD3d7Projection {
|
||||
viewport: [0, 0, 1024, 768],
|
||||
near_plane: 0.5,
|
||||
far_plane: 700.0,
|
||||
field_of_view_radians: 1.3,
|
||||
};
|
||||
|
||||
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
|
||||
.expect("recovered camera inputs are valid");
|
||||
// The raw identity camera's recovered view maps world +X to depth
|
||||
// and world +Z to camera up.
|
||||
let world_up_at_positive_depth = [10.0, 0.0, 1.0, 1.0];
|
||||
let clip_y = (0..4)
|
||||
.map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 1])
|
||||
.sum::<f32>();
|
||||
let clip_w = (0..4)
|
||||
.map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3])
|
||||
.sum::<f32>();
|
||||
|
||||
assert!(clip_w > 0.0);
|
||||
assert!(
|
||||
clip_y / clip_w < 0.0,
|
||||
"world up must map to the viewport top"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
|
||||
let identity = VulkanStaticCamera::default().clip_from_world;
|
||||
@@ -387,6 +949,8 @@ mod static_mesh_tests {
|
||||
first_index: 0,
|
||||
index_count: 2,
|
||||
material_index: 0,
|
||||
lightmap_index: u8::MAX,
|
||||
batch_flags: 0,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
}],
|
||||
@@ -415,6 +979,8 @@ mod static_mesh_tests {
|
||||
first_index: 0,
|
||||
index_count: 3,
|
||||
material_index: 7,
|
||||
lightmap_index: u8::MAX,
|
||||
batch_flags: 0,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
},
|
||||
@@ -422,6 +988,8 @@ mod static_mesh_tests {
|
||||
first_index: 3,
|
||||
index_count: 3,
|
||||
material_index: 2,
|
||||
lightmap_index: u8::MAX,
|
||||
batch_flags: 0,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
},
|
||||
@@ -430,15 +998,58 @@ mod static_mesh_tests {
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba8: vec![255; 4],
|
||||
mip_levels: Vec::new(),
|
||||
};
|
||||
let materials = [
|
||||
VulkanStaticMaterial {
|
||||
material_index: 2,
|
||||
texture: texture(),
|
||||
detail_texture: None,
|
||||
overlay_texture: None,
|
||||
overlay_detail_texture: None,
|
||||
diffuse_alpha: 1.0,
|
||||
overlay_diffuse_alpha: None,
|
||||
directional_rgb: [1.0; 3],
|
||||
additive_rgb: [0.0; 3],
|
||||
specular_rgb: [0.0; 3],
|
||||
specular_power: 0,
|
||||
overlay_specular_rgb: None,
|
||||
overlay_specular_power: None,
|
||||
uv_transform: [0.0, 0.0, 1.0, 1.0],
|
||||
overlay_directional_rgb: None,
|
||||
overlay_additive_rgb: None,
|
||||
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
||||
overlay_uv_transform: None,
|
||||
overlay_detail_uv_transform: None,
|
||||
unlit: false,
|
||||
sky_nebula_stars: false,
|
||||
lightmap_mode: false,
|
||||
sky_far_depth: false,
|
||||
},
|
||||
VulkanStaticMaterial {
|
||||
material_index: 7,
|
||||
texture: texture(),
|
||||
detail_texture: None,
|
||||
overlay_texture: None,
|
||||
overlay_detail_texture: None,
|
||||
diffuse_alpha: 1.0,
|
||||
overlay_diffuse_alpha: None,
|
||||
directional_rgb: [1.0; 3],
|
||||
additive_rgb: [0.0; 3],
|
||||
specular_rgb: [0.0; 3],
|
||||
specular_power: 0,
|
||||
overlay_specular_rgb: None,
|
||||
overlay_specular_power: None,
|
||||
uv_transform: [0.0, 0.0, 1.0, 1.0],
|
||||
overlay_directional_rgb: None,
|
||||
overlay_additive_rgb: None,
|
||||
detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
|
||||
overlay_uv_transform: None,
|
||||
overlay_detail_uv_transform: None,
|
||||
unlit: false,
|
||||
sky_nebula_stars: false,
|
||||
lightmap_mode: false,
|
||||
sky_far_depth: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -480,6 +1091,24 @@ mod static_mesh_tests {
|
||||
};
|
||||
assert_eq!(disabled.alpha_test_cutoff(), 0.0);
|
||||
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0);
|
||||
let explicit_zero = VulkanStaticDrawRange {
|
||||
pipeline_state: LegacyPipelineState {
|
||||
alpha_test: true,
|
||||
..LegacyPipelineState::default()
|
||||
},
|
||||
alpha_test_reference: 0,
|
||||
..base
|
||||
};
|
||||
assert_eq!(explicit_zero.alpha_test_cutoff(), 0.0);
|
||||
let native_default = VulkanStaticDrawRange {
|
||||
pipeline_state: LegacyPipelineState {
|
||||
alpha_test: true,
|
||||
..LegacyPipelineState::default()
|
||||
},
|
||||
alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
|
||||
..base
|
||||
};
|
||||
assert_eq!(native_default.alpha_test_cutoff(), 1.0 / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,10 +1356,46 @@ pub struct VulkanSmokeRenderer {
|
||||
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
|
||||
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
|
||||
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
|
||||
/// Positional material slots mapped to the owned image list. Fallback
|
||||
/// stages may share one image without coupling independently animated
|
||||
/// material textures.
|
||||
pub(super) texture_slot_indices: Vec<usize>,
|
||||
pub(super) textures: Vec<VulkanAllocatedImage>,
|
||||
pub(super) vertex_count: usize,
|
||||
/// Retained index allocation length. The upload path grows this only when
|
||||
/// a frame actually needs more shared indices and never shrinks it.
|
||||
pub(super) index_capacity: usize,
|
||||
pub(super) draw_range_capacities: Vec<u32>,
|
||||
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
|
||||
pub(super) draw_texture_indices: Vec<usize>,
|
||||
/// Stable range handles used when recording the native pass order.
|
||||
pub(super) draw_order: Vec<usize>,
|
||||
/// Native camera-sort values returned by the draw-item camera callback.
|
||||
pub(super) draw_range_sort_keys: Vec<Option<f32>>,
|
||||
/// Whether the stable order must be rebuilt before the next command
|
||||
/// buffer. Range setters are called in batches by the game loop.
|
||||
pub(super) draw_order_dirty: bool,
|
||||
/// World queue classification for each stable range handle. `None` keeps
|
||||
/// a fixed screen/sky/weather pass; `Some(false)` is world opaque and
|
||||
/// `Some(true)` is world transparent.
|
||||
pub(super) draw_range_world_queue: Vec<Option<bool>>,
|
||||
/// Pipeline state before transparent queue depth policy is applied.
|
||||
pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
|
||||
pub(super) material_alphas: Vec<[f32; 2]>,
|
||||
pub(super) material_lighting: Vec<[[f32; 3]; 4]>,
|
||||
pub(super) material_uv_transforms: Vec<[[f32; 4]; 4]>,
|
||||
/// Base and overlay specular records uploaded to the dynamic SSBO.
|
||||
pub(super) material_specular: Vec<[[f32; 4]; 2]>,
|
||||
/// Original selector for each positional material record.
|
||||
pub(super) material_selectors: Vec<u16>,
|
||||
/// Whether the host-side specular records need a fence-safe GPU upload.
|
||||
pub(super) material_specular_dirty: bool,
|
||||
/// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
|
||||
/// sky nebula/stars texture composite, 3 native lightmap composite.
|
||||
pub(super) material_unlit: Vec<f32>,
|
||||
pub(super) readback_enabled: bool,
|
||||
pub(super) camera: VulkanStaticCamera,
|
||||
pub(super) frame_uniforms: VulkanFrameUniforms,
|
||||
pub(super) frame_sync: Vec<VulkanFrameSync>,
|
||||
pub(super) images_in_flight: Vec<vk::Fence>,
|
||||
pub(super) current_frame: usize,
|
||||
|
||||
@@ -8,38 +8,85 @@ use fparkan_render::{
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
color_subresource_range, create_depth_attachment, destroy_depth_attachment,
|
||||
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe,
|
||||
VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
|
||||
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
|
||||
color_subresource_range, create_depth_attachment, create_material_specular_buffer,
|
||||
destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment,
|
||||
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
|
||||
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES,
|
||||
VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
|
||||
};
|
||||
|
||||
pub(super) struct VulkanSwapchainResources {
|
||||
pub(super) image_views: Vec<vk::ImageView>,
|
||||
pub(super) images: Vec<vk::Image>,
|
||||
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
|
||||
pub(super) depth_attachment: VulkanDepthAttachment,
|
||||
/// One depth image per swapchain image so frames can overlap safely.
|
||||
pub(super) depth_attachments: Vec<VulkanDepthAttachment>,
|
||||
/// A render-finished semaphore dedicated to each swapchain image.
|
||||
pub(super) render_finished: Vec<vk::Semaphore>,
|
||||
pub(super) render_pass: vk::RenderPass,
|
||||
pub(super) pipeline_layout: vk::PipelineLayout,
|
||||
pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
|
||||
pub(super) descriptor_pool: vk::DescriptorPool,
|
||||
pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
|
||||
pub(super) sampler: vk::Sampler,
|
||||
pub(super) frame_uniform_buffer: VulkanAllocatedBuffer,
|
||||
pub(super) frame_uniform_stride: u64,
|
||||
pub(super) material_specular_buffer: VulkanAllocatedBuffer,
|
||||
pub(super) material_specular_stride: u64,
|
||||
/// Live graphics pipelines indexed by canonical backend-neutral state.
|
||||
pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
|
||||
pub(super) framebuffers: Vec<vk::Framebuffer>,
|
||||
pub(super) command_buffers: Vec<vk::CommandBuffer>,
|
||||
}
|
||||
|
||||
pub(super) fn update_material_texture_descriptor(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
resources: &VulkanSwapchainResources,
|
||||
material_index: usize,
|
||||
texture_slot: usize,
|
||||
texture: &VulkanAllocatedImage,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
if texture_slot >= 4 {
|
||||
return Err(VulkanSmokeRendererError::InvalidStaticTexture {
|
||||
context: "dynamic material texture slot is out of bounds",
|
||||
});
|
||||
}
|
||||
let descriptor_set = resources
|
||||
.descriptor_sets
|
||||
.get(material_index)
|
||||
.copied()
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticTexture {
|
||||
context: "dynamic material descriptor set is out of bounds",
|
||||
})?;
|
||||
let image_info = vk::DescriptorImageInfo::default()
|
||||
.sampler(resources.sampler)
|
||||
.image_view(texture.view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
|
||||
let write = vk::WriteDescriptorSet::default()
|
||||
.dst_set(descriptor_set)
|
||||
.dst_binding(u32::try_from(texture_slot).unwrap_or(u32::MAX))
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(std::slice::from_ref(&image_info));
|
||||
// SAFETY: the renderer idles the device before changing a descriptor that
|
||||
// may be referenced by an in-flight command buffer.
|
||||
unsafe { device.device().update_descriptor_sets(&[write], &[]) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct PartialSwapchainResources {
|
||||
image_views: Vec<vk::ImageView>,
|
||||
readback_buffers: Vec<VulkanAllocatedBuffer>,
|
||||
depth_attachment: Option<VulkanDepthAttachment>,
|
||||
depth_attachments: Vec<VulkanDepthAttachment>,
|
||||
render_finished: Vec<vk::Semaphore>,
|
||||
render_pass: Option<vk::RenderPass>,
|
||||
pipeline_layout: Option<vk::PipelineLayout>,
|
||||
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
|
||||
descriptor_pool: Option<vk::DescriptorPool>,
|
||||
sampler: Option<vk::Sampler>,
|
||||
frame_uniform_buffer: Option<VulkanAllocatedBuffer>,
|
||||
frame_uniform_stride: u64,
|
||||
material_specular_buffer: Option<VulkanAllocatedBuffer>,
|
||||
material_specular_stride: u64,
|
||||
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
|
||||
framebuffers: Vec<vk::Framebuffer>,
|
||||
command_buffers: Vec<vk::CommandBuffer>,
|
||||
@@ -53,6 +100,7 @@ pub(super) fn create_swapchain_resources(
|
||||
vertex_buffer: &VulkanAllocatedBuffer,
|
||||
index_buffer: &VulkanAllocatedBuffer,
|
||||
textures: &[VulkanAllocatedImage],
|
||||
texture_slot_indices: &[usize],
|
||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
||||
depth_request: DepthStencilSupport,
|
||||
reuse_command_pool: bool,
|
||||
@@ -74,16 +122,57 @@ pub(super) fn create_swapchain_resources(
|
||||
swapchain.report.plan.format.format,
|
||||
)?,
|
||||
readback_buffers: Vec::new(),
|
||||
depth_attachment: None,
|
||||
depth_attachments: Vec::new(),
|
||||
render_finished: Vec::new(),
|
||||
render_pass: None,
|
||||
pipeline_layout: None,
|
||||
descriptor_set_layout: None,
|
||||
descriptor_pool: None,
|
||||
sampler: None,
|
||||
frame_uniform_buffer: None,
|
||||
frame_uniform_stride: 0,
|
||||
material_specular_buffer: None,
|
||||
material_specular_stride: 0,
|
||||
pipelines: BTreeMap::new(),
|
||||
framebuffers: Vec::new(),
|
||||
command_buffers: Vec::new(),
|
||||
};
|
||||
let semaphore_info = vk::SemaphoreCreateInfo::default();
|
||||
for _ in &images {
|
||||
// SAFETY: The semaphore belongs to this live logical device and is
|
||||
// destroyed with the swapchain resources.
|
||||
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
|
||||
Ok(semaphore) => partial.render_finished.push(semaphore),
|
||||
Err(result) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSemaphore(render_finished)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let (frame_uniform_buffer, frame_uniform_stride) =
|
||||
match super::create_frame_uniform_buffer(instance, device) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.frame_uniform_stride = frame_uniform_stride;
|
||||
partial.frame_uniform_buffer = Some(frame_uniform_buffer);
|
||||
let material_count = texture_slot_indices.len() / 4;
|
||||
let (material_specular_buffer, material_specular_stride) =
|
||||
match create_material_specular_buffer(instance, device, material_count) {
|
||||
Ok(buffer) => buffer,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.material_specular_stride = material_specular_stride;
|
||||
partial.material_specular_buffer = Some(material_specular_buffer);
|
||||
let (
|
||||
depth_attachment,
|
||||
render_pass,
|
||||
@@ -99,8 +188,17 @@ pub(super) fn create_swapchain_resources(
|
||||
swapchain.report.plan.format.format,
|
||||
swapchain.report.plan.extent,
|
||||
textures,
|
||||
texture_slot_indices,
|
||||
draw_ranges,
|
||||
depth_request,
|
||||
partial
|
||||
.frame_uniform_buffer
|
||||
.as_ref()
|
||||
.expect("frame uniform buffer is present before descriptor creation"),
|
||||
partial
|
||||
.material_specular_buffer
|
||||
.as_ref()
|
||||
.expect("material specular buffer is present before descriptor creation"),
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(error) => {
|
||||
@@ -108,19 +206,35 @@ pub(super) fn create_swapchain_resources(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let depth_view = depth_attachment.image.view;
|
||||
partial.depth_attachment = Some(depth_attachment);
|
||||
// Keep every handle from the bundle in the partial owner before creating
|
||||
// the remaining per-image depth attachments. That loop can fail after the
|
||||
// first attachment, and its error path must destroy the whole bundle.
|
||||
partial.depth_attachments.push(depth_attachment);
|
||||
partial.render_pass = Some(render_pass);
|
||||
partial.pipeline_layout = Some(pipeline_layout);
|
||||
partial.descriptor_set_layout = Some(descriptor_set_layout);
|
||||
partial.descriptor_pool = Some(descriptor_pool);
|
||||
partial.sampler = Some(sampler);
|
||||
partial.pipelines = pipelines;
|
||||
for _ in 1..images.len() {
|
||||
match create_depth_attachment(
|
||||
instance,
|
||||
device,
|
||||
swapchain.report.plan.extent,
|
||||
depth_request,
|
||||
) {
|
||||
Ok(attachment) => partial.depth_attachments.push(attachment),
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
let framebuffers = match create_swapchain_framebuffers(
|
||||
device,
|
||||
render_pass,
|
||||
&partial.image_views,
|
||||
depth_view,
|
||||
&partial.depth_attachments,
|
||||
swapchain.report.plan.extent,
|
||||
) {
|
||||
Ok(framebuffers) => framebuffers,
|
||||
@@ -168,24 +282,44 @@ pub(super) fn create_swapchain_resources(
|
||||
};
|
||||
partial.command_buffers = command_buffers;
|
||||
let _ = (vertex_buffer, index_buffer);
|
||||
let depth_attachment =
|
||||
partial
|
||||
.depth_attachment
|
||||
.take()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "depth attachment ownership after swapchain setup",
|
||||
})?;
|
||||
if partial.depth_attachments.len() != images.len()
|
||||
|| partial.render_finished.len() != images.len()
|
||||
{
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "swapchain per-image resource count after setup",
|
||||
});
|
||||
}
|
||||
Ok(VulkanSwapchainResources {
|
||||
image_views: partial.image_views,
|
||||
images,
|
||||
readback_buffers: partial.readback_buffers,
|
||||
depth_attachment,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
descriptor_set_layout,
|
||||
descriptor_pool,
|
||||
depth_attachments: partial.depth_attachments,
|
||||
render_finished: partial.render_finished,
|
||||
render_pass: partial
|
||||
.render_pass
|
||||
.expect("render pass is present after successful swapchain setup"),
|
||||
pipeline_layout: partial
|
||||
.pipeline_layout
|
||||
.expect("pipeline layout is present after successful swapchain setup"),
|
||||
descriptor_set_layout: partial
|
||||
.descriptor_set_layout
|
||||
.expect("descriptor set layout is present after successful swapchain setup"),
|
||||
descriptor_pool: partial
|
||||
.descriptor_pool
|
||||
.expect("descriptor pool is present after successful swapchain setup"),
|
||||
descriptor_sets,
|
||||
sampler,
|
||||
sampler: partial
|
||||
.sampler
|
||||
.expect("sampler is present after successful swapchain setup"),
|
||||
frame_uniform_buffer: partial
|
||||
.frame_uniform_buffer
|
||||
.expect("frame uniform buffer is present after successful swapchain setup"),
|
||||
frame_uniform_stride: partial.frame_uniform_stride,
|
||||
material_specular_buffer: partial
|
||||
.material_specular_buffer
|
||||
.expect("material specular buffer is present after successful swapchain setup"),
|
||||
material_specular_stride: partial.material_specular_stride,
|
||||
pipelines: partial.pipelines,
|
||||
framebuffers: partial.framebuffers,
|
||||
command_buffers: partial.command_buffers,
|
||||
@@ -210,8 +344,11 @@ fn create_swapchain_pipeline_bundle(
|
||||
format: i32,
|
||||
extent: (u32, u32),
|
||||
textures: &[VulkanAllocatedImage],
|
||||
texture_slot_indices: &[usize],
|
||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
||||
depth_request: DepthStencilSupport,
|
||||
frame_uniform_buffer: &VulkanAllocatedBuffer,
|
||||
material_specular_buffer: &VulkanAllocatedBuffer,
|
||||
) -> Result<
|
||||
(
|
||||
VulkanDepthAttachment,
|
||||
@@ -234,7 +371,14 @@ fn create_swapchain_pipeline_bundle(
|
||||
}
|
||||
};
|
||||
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
|
||||
create_texture_descriptor_bundle(device, textures).inspect_err(|_| {
|
||||
create_texture_descriptor_bundle(
|
||||
device,
|
||||
textures,
|
||||
texture_slot_indices,
|
||||
frame_uniform_buffer,
|
||||
material_specular_buffer,
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
|
||||
unsafe { device.device().destroy_render_pass(render_pass, None) };
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
@@ -289,12 +433,23 @@ fn create_swapchain_framebuffers(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
image_views: &[vk::ImageView],
|
||||
depth_view: vk::ImageView,
|
||||
depth_attachments: &[VulkanDepthAttachment],
|
||||
extent: (u32, u32),
|
||||
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
|
||||
if image_views.len() != depth_attachments.len() {
|
||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "swapchain color/depth attachment count",
|
||||
});
|
||||
}
|
||||
let mut framebuffers = Vec::with_capacity(image_views.len());
|
||||
for image_view in image_views.iter().copied() {
|
||||
match create_framebuffer(device, render_pass, image_view, depth_view, extent) {
|
||||
for (image_view, depth_attachment) in image_views.iter().copied().zip(depth_attachments) {
|
||||
match create_framebuffer(
|
||||
device,
|
||||
render_pass,
|
||||
image_view,
|
||||
depth_attachment.image.view,
|
||||
extent,
|
||||
) {
|
||||
Ok(framebuffer) => framebuffers.push(framebuffer),
|
||||
Err(error) => {
|
||||
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
|
||||
@@ -339,7 +494,7 @@ fn create_image_view(
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(vk::Format::from_raw(format))
|
||||
.subresource_range(color_subresource_range());
|
||||
.subresource_range(color_subresource_range(1));
|
||||
// SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect.
|
||||
unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
@@ -384,10 +539,21 @@ fn create_render_pass(
|
||||
let dependency = vk::SubpassDependency::default()
|
||||
.src_subpass(vk::SUBPASS_EXTERNAL)
|
||||
.dst_subpass(0)
|
||||
.src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||
.dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT)
|
||||
.src_stage_mask(
|
||||
vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
|
||||
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
|
||||
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
|
||||
)
|
||||
.dst_stage_mask(
|
||||
vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
|
||||
| vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
|
||||
| vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
|
||||
)
|
||||
.src_access_mask(vk::AccessFlags::empty())
|
||||
.dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
|
||||
.dst_access_mask(
|
||||
vk::AccessFlags::COLOR_ATTACHMENT_WRITE
|
||||
| vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
|
||||
);
|
||||
let attachments = [color_attachment, depth_attachment];
|
||||
let subpasses = [subpass];
|
||||
let dependencies = [dependency];
|
||||
@@ -409,16 +575,16 @@ fn create_pipeline_layout(
|
||||
descriptor_set_layout: vk::DescriptorSetLayout,
|
||||
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
|
||||
let set_layouts = [descriptor_set_layout];
|
||||
let push_constant_ranges = [
|
||||
vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::VERTEX)
|
||||
// Material data occupies one 128-byte push-constant allocation shared by
|
||||
// both stages; the vertex stage also reads its specular pair from the
|
||||
// dynamic storage buffer at descriptor binding 5. Camera data is supplied
|
||||
// by the dynamic frame UBO at descriptor binding 4.
|
||||
// Expose the complete block from offset zero so drivers validate the
|
||||
// fragment member's declared offset against the pipeline layout.
|
||||
let push_constant_ranges = [vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)
|
||||
.offset(0)
|
||||
.size(64),
|
||||
vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.offset(64)
|
||||
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
];
|
||||
.size(32 * u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX))];
|
||||
let create_info = vk::PipelineLayoutCreateInfo::default()
|
||||
.set_layouts(&set_layouts)
|
||||
.push_constant_ranges(&push_constant_ranges);
|
||||
@@ -434,6 +600,9 @@ fn create_pipeline_layout(
|
||||
fn create_texture_descriptor_bundle(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
textures: &[VulkanAllocatedImage],
|
||||
texture_slot_indices: &[usize],
|
||||
frame_uniform_buffer: &VulkanAllocatedBuffer,
|
||||
material_specular_buffer: &VulkanAllocatedBuffer,
|
||||
) -> Result<
|
||||
(
|
||||
vk::DescriptorSetLayout,
|
||||
@@ -443,12 +612,32 @@ fn create_texture_descriptor_bundle(
|
||||
),
|
||||
VulkanSmokeRendererError,
|
||||
> {
|
||||
let binding = vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
const TEXTURES_PER_MATERIAL: usize = 4;
|
||||
const FRAME_BINDING: u32 = 4;
|
||||
const MATERIAL_SPECULAR_BINDING: u32 = 5;
|
||||
let mut bindings = (0..TEXTURES_PER_MATERIAL)
|
||||
.map(|binding| {
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(u32::try_from(binding).unwrap_or(u32::MAX))
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
|
||||
let bindings = [binding];
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
bindings.push(
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(FRAME_BINDING)
|
||||
.descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT),
|
||||
);
|
||||
bindings.push(
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(MATERIAL_SPECULAR_BINDING)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::VERTEX),
|
||||
);
|
||||
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
|
||||
// SAFETY: The layout description is stack-owned and references no external memory.
|
||||
let layout = unsafe {
|
||||
@@ -460,23 +649,40 @@ fn create_texture_descriptor_bundle(
|
||||
context: "vkCreateDescriptorSetLayout",
|
||||
result,
|
||||
})?;
|
||||
let texture_count = u32::try_from(textures.len()).map_err(|_| {
|
||||
let texture_count = u32::try_from(texture_slot_indices.len()).map_err(|_| {
|
||||
VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "static material texture count exceeds Vulkan descriptor limit",
|
||||
}
|
||||
})?;
|
||||
if texture_count == 0 {
|
||||
if texture_count == 0
|
||||
|| !texture_slot_indices
|
||||
.len()
|
||||
.is_multiple_of(TEXTURES_PER_MATERIAL)
|
||||
|| texture_slot_indices
|
||||
.iter()
|
||||
.any(|texture_index| *texture_index >= textures.len())
|
||||
{
|
||||
// SAFETY: The layout was created above on this device and is rolled
|
||||
// back before reporting malformed material-slot input.
|
||||
unsafe { device.device().destroy_descriptor_set_layout(layout, None) };
|
||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "static material texture list is empty",
|
||||
context: "static material texture slots are invalid",
|
||||
});
|
||||
}
|
||||
let material_count = texture_count / u32::try_from(TEXTURES_PER_MATERIAL).unwrap_or(1);
|
||||
let pool_size = vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(texture_count);
|
||||
let pool_sizes = [pool_size];
|
||||
let frame_pool_size = vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
|
||||
.descriptor_count(material_count);
|
||||
let material_specular_pool_size = vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
|
||||
.descriptor_count(material_count);
|
||||
let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size];
|
||||
let pool_info = vk::DescriptorPoolCreateInfo::default()
|
||||
.pool_sizes(&pool_sizes)
|
||||
.max_sets(texture_count);
|
||||
.max_sets(material_count);
|
||||
let pool =
|
||||
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
|
||||
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
|
||||
@@ -487,7 +693,7 @@ fn create_texture_descriptor_bundle(
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
let layouts = vec![layout; textures.len()];
|
||||
let layouts = vec![layout; usize::try_from(material_count).unwrap_or(0)];
|
||||
let allocate_info = vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(pool)
|
||||
.set_layouts(&layouts);
|
||||
@@ -511,7 +717,9 @@ fn create_texture_descriptor_bundle(
|
||||
.address_mode_u(vk::SamplerAddressMode::REPEAT)
|
||||
.address_mode_v(vk::SamplerAddressMode::REPEAT)
|
||||
.address_mode_w(vk::SamplerAddressMode::REPEAT)
|
||||
.max_lod(0.0);
|
||||
// The uploaded images carry a complete CPU-generated mip chain; the
|
||||
// sampler clamps to the largest legal level for each image.
|
||||
.max_lod(16.0);
|
||||
let sampler =
|
||||
// SAFETY: The sampler create info is stack-owned and has no unsupported optional features.
|
||||
unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| {
|
||||
@@ -525,27 +733,51 @@ fn create_texture_descriptor_bundle(
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
let image_infos = textures
|
||||
let image_infos = texture_slot_indices
|
||||
.iter()
|
||||
.map(|texture| {
|
||||
.map(|texture_index| {
|
||||
let texture = &textures[*texture_index];
|
||||
vk::DescriptorImageInfo::default()
|
||||
.sampler(sampler)
|
||||
.image_view(texture.view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let writes = descriptor_sets
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(image_infos.iter())
|
||||
.map(|(descriptor_set, image_info)| {
|
||||
let frame_info = vk::DescriptorBufferInfo::default()
|
||||
.buffer(frame_uniform_buffer.buffer)
|
||||
.offset(0)
|
||||
.range(VULKAN_FRAME_UNIFORM_BYTES as u64);
|
||||
let material_specular_info = vk::DescriptorBufferInfo::default()
|
||||
.buffer(material_specular_buffer.buffer)
|
||||
.offset(0)
|
||||
.range(VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64);
|
||||
let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 2));
|
||||
for (set_index, descriptor_set) in descriptor_sets.iter().copied().enumerate() {
|
||||
for binding in 0..TEXTURES_PER_MATERIAL {
|
||||
let image_info = &image_infos[set_index * TEXTURES_PER_MATERIAL + binding];
|
||||
writes.push(
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(descriptor_set)
|
||||
.dst_binding(0)
|
||||
.dst_binding(u32::try_from(binding).unwrap_or(u32::MAX))
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(std::slice::from_ref(image_info))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
.image_info(std::slice::from_ref(image_info)),
|
||||
);
|
||||
}
|
||||
writes.push(
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(descriptor_set)
|
||||
.dst_binding(FRAME_BINDING)
|
||||
.descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
|
||||
.buffer_info(std::slice::from_ref(&frame_info)),
|
||||
);
|
||||
writes.push(
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(descriptor_set)
|
||||
.dst_binding(MATERIAL_SPECULAR_BINDING)
|
||||
.descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
|
||||
.buffer_info(std::slice::from_ref(&material_specular_info)),
|
||||
);
|
||||
}
|
||||
// SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition.
|
||||
unsafe { device.device().update_descriptor_sets(&writes, &[]) };
|
||||
Ok((layout, pool, descriptor_sets, sampler))
|
||||
@@ -598,8 +830,9 @@ fn create_graphics_pipeline(
|
||||
extent: (u32, u32),
|
||||
state: LegacyPipelineState,
|
||||
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
|
||||
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
|
||||
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
|
||||
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS, "vertex")?;
|
||||
let fragment_shader =
|
||||
match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS, "fragment") {
|
||||
Ok(module) => module,
|
||||
Err(error) => {
|
||||
// SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure.
|
||||
@@ -620,7 +853,7 @@ fn create_graphics_pipeline(
|
||||
];
|
||||
let vertex_binding = vk::VertexInputBindingDescription::default()
|
||||
.binding(0)
|
||||
.stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
|
||||
.stride(u32::try_from(14 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
|
||||
.input_rate(vk::VertexInputRate::VERTEX);
|
||||
let vertex_attributes = [
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
@@ -636,8 +869,23 @@ fn create_graphics_pipeline(
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(2)
|
||||
.format(vk::Format::R32G32_SFLOAT)
|
||||
.format(vk::Format::R32G32B32_SFLOAT)
|
||||
.offset(u32::try_from(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(3)
|
||||
.format(vk::Format::R32G32_SFLOAT)
|
||||
.offset(u32::try_from(9 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(4)
|
||||
.format(vk::Format::R32G32_SFLOAT)
|
||||
.offset(u32::try_from(11 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(5)
|
||||
.format(vk::Format::R32_SFLOAT)
|
||||
.offset(u32::try_from(13 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
];
|
||||
let vertex_bindings = [vertex_binding];
|
||||
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
|
||||
@@ -685,6 +933,21 @@ fn create_graphics_pipeline(
|
||||
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
|
||||
.depth_bounds_test_enable(false)
|
||||
.stencil_test_enable(false);
|
||||
let (blend_enable, src_color_blend_factor, dst_color_blend_factor) = match state.blend {
|
||||
LegacyBlendMode::Opaque => (false, vk::BlendFactor::ONE, vk::BlendFactor::ZERO),
|
||||
LegacyBlendMode::SourceAlpha => (
|
||||
true,
|
||||
vk::BlendFactor::SRC_ALPHA,
|
||||
vk::BlendFactor::ONE_MINUS_SRC_ALPHA,
|
||||
),
|
||||
LegacyBlendMode::Additive => (true, vk::BlendFactor::SRC_ALPHA, vk::BlendFactor::ONE),
|
||||
LegacyBlendMode::ZeroSourceColor => {
|
||||
(true, vk::BlendFactor::ZERO, vk::BlendFactor::SRC_COLOR)
|
||||
}
|
||||
LegacyBlendMode::DestColorSourceColor => {
|
||||
(true, vk::BlendFactor::DST_COLOR, vk::BlendFactor::SRC_COLOR)
|
||||
}
|
||||
};
|
||||
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
|
||||
.color_write_mask(
|
||||
vk::ColorComponentFlags::R
|
||||
@@ -692,12 +955,12 @@ fn create_graphics_pipeline(
|
||||
| vk::ColorComponentFlags::B
|
||||
| vk::ColorComponentFlags::A,
|
||||
)
|
||||
.blend_enable(state.blend == LegacyBlendMode::SourceAlpha)
|
||||
.src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
|
||||
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
||||
.blend_enable(blend_enable)
|
||||
.src_color_blend_factor(src_color_blend_factor)
|
||||
.dst_color_blend_factor(dst_color_blend_factor)
|
||||
.color_blend_op(vk::BlendOp::ADD)
|
||||
.src_alpha_blend_factor(vk::BlendFactor::ONE)
|
||||
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
||||
.src_alpha_blend_factor(src_color_blend_factor)
|
||||
.dst_alpha_blend_factor(dst_color_blend_factor)
|
||||
.alpha_blend_op(vk::BlendOp::ADD);
|
||||
let color_blend_attachments = [color_blend_attachment];
|
||||
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
|
||||
@@ -738,12 +1001,17 @@ fn create_graphics_pipeline(
|
||||
fn create_shader_module(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
words: &[u32],
|
||||
stage: &'static str,
|
||||
) -> Result<vk::ShaderModule, VulkanSmokeRendererError> {
|
||||
let create_info = vk::ShaderModuleCreateInfo::default().code(words);
|
||||
// SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call.
|
||||
unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateShaderModule",
|
||||
context: if stage == "vertex" {
|
||||
"vkCreateShaderModule(vertex)"
|
||||
} else {
|
||||
"vkCreateShaderModule(fragment)"
|
||||
},
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
@@ -821,7 +1089,9 @@ pub(super) fn destroy_swapchain_resources(
|
||||
device
|
||||
.device()
|
||||
.destroy_render_pass(resources.render_pass, None);
|
||||
destroy_depth_attachment(device, &resources.depth_attachment);
|
||||
for depth_attachment in resources.depth_attachments {
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
}
|
||||
for image_view in resources.image_views {
|
||||
device.device().destroy_image_view(image_view, None);
|
||||
}
|
||||
@@ -829,6 +1099,21 @@ pub(super) fn destroy_swapchain_resources(
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
for semaphore in resources.render_finished {
|
||||
device.device().destroy_semaphore(semaphore, None);
|
||||
}
|
||||
device
|
||||
.device()
|
||||
.destroy_buffer(resources.frame_uniform_buffer.buffer, None);
|
||||
device
|
||||
.device()
|
||||
.free_memory(resources.frame_uniform_buffer.memory, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_buffer(resources.material_specular_buffer.buffer, None);
|
||||
device
|
||||
.device()
|
||||
.free_memory(resources.material_specular_buffer.memory, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,7 +1156,7 @@ fn destroy_partial_swapchain_resources(
|
||||
if let Some(render_pass) = partial.render_pass {
|
||||
device.device().destroy_render_pass(render_pass, None);
|
||||
}
|
||||
if let Some(depth_attachment) = partial.depth_attachment {
|
||||
for depth_attachment in partial.depth_attachments {
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
}
|
||||
for image_view in partial.image_views {
|
||||
@@ -881,5 +1166,16 @@ fn destroy_partial_swapchain_resources(
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
for semaphore in partial.render_finished {
|
||||
device.device().destroy_semaphore(semaphore, None);
|
||||
}
|
||||
if let Some(buffer) = partial.frame_uniform_buffer {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
if let Some(buffer) = partial.material_specular_buffer {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,13 +386,13 @@ fn static_surface_extension_name_is_decoded() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
|
||||
fn swapchain_plan_prefers_unorm_mailbox_and_clamps_extent() {
|
||||
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.format,
|
||||
VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
}
|
||||
);
|
||||
@@ -444,7 +444,7 @@ fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() {
|
||||
assert_eq!(
|
||||
plan.format,
|
||||
VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
|
||||
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
|
||||
}
|
||||
);
|
||||
@@ -479,6 +479,66 @@ fn checked_in_shaders_have_valid_spirv_containers() {
|
||||
assert_eq!(validate_triangle_shaders(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragment_shader_dispatches_all_four_material_modes() {
|
||||
let fragment = include_str!("../../shaders/triangle.frag");
|
||||
assert!(fragment.contains("int mode = int(round(material.material_mode));"));
|
||||
assert!(fragment.contains("mode == 0"));
|
||||
assert!(fragment.contains("mode == 1"));
|
||||
assert!(fragment.contains("mode == 2"));
|
||||
assert!(fragment.contains("mode == 3"));
|
||||
assert!(!fragment.contains("material_mode > 1.5"));
|
||||
assert!(!fragment.contains("material_mode > 2.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_specular_reference_uses_squared_power_and_total_overflow_curve() {
|
||||
fn reference_power(cosine: f32, power: u8) -> f32 {
|
||||
if power == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut result = cosine;
|
||||
for _ in 0..power.saturating_sub(1) {
|
||||
result *= result;
|
||||
}
|
||||
result
|
||||
}
|
||||
fn reference_curve(value: f32) -> f32 {
|
||||
if value <= 1.0 {
|
||||
0.8 * value
|
||||
} else if value <= 3.0 {
|
||||
0.1 * value + 0.7
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(reference_power(0.5, 0), 0.0);
|
||||
assert!((reference_power(0.5, 1) - 0.5).abs() < 1e-6);
|
||||
assert!((reference_power(0.5, 2) - 0.25).abs() < 1e-6);
|
||||
assert!((reference_power(0.5, 3) - 0.0625).abs() < 1e-6);
|
||||
let compressed_diffuse = 1.5;
|
||||
let material_specular = 0.75;
|
||||
assert!((reference_curve(material_specular + (compressed_diffuse - 1.0)) - 0.825).abs() < 1e-6);
|
||||
|
||||
let vertex = include_str!("../../shaders/triangle.vert");
|
||||
assert!(vertex.contains("result *= result;"));
|
||||
assert!(
|
||||
vertex.contains("vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));")
|
||||
);
|
||||
assert!(vertex.contains("if (power <= 0.0 || cosine <= 0.0)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_computes_radial_fog_per_vertex_and_interpolates_it() {
|
||||
let vertex = include_str!("../../shaders/triangle.vert");
|
||||
let fragment = include_str!("../../shaders/triangle.frag");
|
||||
assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);"));
|
||||
assert!(fragment.contains("layout(location = 4) in float in_fog_factor;"));
|
||||
assert!(!fragment.contains("distance(in_world_position"));
|
||||
assert!(!fragment.contains("float fog_factor()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_container_rejects_invalid_spirv() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -458,9 +458,24 @@ fn select_surface_format(
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|format| {
|
||||
format.format == vk::Format::R8G8B8A8_UNORM.as_raw()
|
||||
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
|
||||
})
|
||||
.or_else(|| {
|
||||
formats.iter().copied().find(|format| {
|
||||
format.format == vk::Format::B8G8R8A8_UNORM.as_raw()
|
||||
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
|
||||
})
|
||||
})
|
||||
// Keep an sRGB attachment as the final compatibility fallback. The
|
||||
// fragment shader emits legacy byte-equivalent linear values, so an
|
||||
// UNORM attachment is preferred whenever the surface exposes one.
|
||||
.or_else(|| {
|
||||
formats.iter().copied().find(|format| {
|
||||
format.format == vk::Format::B8G8R8A8_SRGB.as_raw()
|
||||
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
|
||||
})
|
||||
})
|
||||
.or_else(|| formats.first().copied())
|
||||
.ok_or(VulkanSwapchainError::MissingSurfaceFormat)
|
||||
}
|
||||
@@ -470,7 +485,7 @@ fn undefined_surface_format_override(
|
||||
) -> Option<VulkanSurfaceFormat> {
|
||||
match formats {
|
||||
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat {
|
||||
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
|
||||
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
|
||||
color_space: format.color_space,
|
||||
}),
|
||||
_ => None,
|
||||
|
||||
@@ -6,14 +6,21 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-animation = { path = "../../crates/fparkan-animation" }
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||
fparkan-fx = { path = "../../crates/fparkan-fx" }
|
||||
fparkan-path = { path = "../../crates/fparkan-path" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||
fparkan-msh = { path = "../../crates/fparkan-msh" }
|
||||
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-terrain = { path = "../../crates/fparkan-terrain" }
|
||||
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
rodio = { version = "0.22.2", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+5559
-203
File diff suppressed because it is too large
Load Diff
@@ -254,12 +254,16 @@ impl AnimKey24 {
|
||||
];
|
||||
let time = AnimationTime(read_f32(bytes, 12)?);
|
||||
validate_time(time)?;
|
||||
let raw_rotation = [
|
||||
let disk_wxyz = [
|
||||
f32::from(read_i16(bytes, 16)?) / 32767.0,
|
||||
f32::from(read_i16(bytes, 18)?) / 32767.0,
|
||||
f32::from(read_i16(bytes, 20)?) / 32767.0,
|
||||
f32::from(read_i16(bytes, 22)?) / 32767.0,
|
||||
];
|
||||
// Native AniMesh stores the scalar first (WXYZ). The portable pose
|
||||
// contract is XYZW, matching the rotate/multiply helpers below. Keep
|
||||
// the decoded values unnormalized; sampling_pose owns normalization.
|
||||
let raw_rotation = [disk_wxyz[1], disk_wxyz[2], disk_wxyz[3], disk_wxyz[0]];
|
||||
Ok(Self {
|
||||
time,
|
||||
pose: Pose {
|
||||
@@ -870,9 +874,47 @@ mod tests {
|
||||
|
||||
assert_eq!(key.time, AnimationTime(12.5));
|
||||
assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
|
||||
assert!(key.pose.rotation[1] < 0.0);
|
||||
assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
|
||||
assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
|
||||
assert!(key.pose.rotation[0] < 0.0);
|
||||
assert!((key.pose.rotation[0] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
|
||||
assert!((key.pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anim_key24_preserves_disk_wxyz_as_internal_xyzw() {
|
||||
let mut bytes = [0_u8; 24];
|
||||
bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
|
||||
bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
|
||||
bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
|
||||
bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
|
||||
bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
|
||||
|
||||
let key = AnimKey24::decode(&bytes).expect("bunker key");
|
||||
let expected = [0.0, 0.0, 3211.0 / 32767.0, 32609.0 / 32767.0];
|
||||
|
||||
assert_eq!(key.pose.rotation, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bunker_fallback_pose_rotates_a_point_with_native_matrix_contract() {
|
||||
let mut bytes = [0_u8; 24];
|
||||
bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
|
||||
bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
|
||||
bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
|
||||
bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
|
||||
bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
|
||||
|
||||
let pose = AnimKey24::decode(&bytes)
|
||||
.expect("bunker key")
|
||||
.sampling_pose();
|
||||
let rotated = rotate_point(pose.rotation, [0.0, 1.0, 0.0]);
|
||||
|
||||
assert!((pose.rotation[0] - 0.0).abs() < 0.000_001);
|
||||
assert!((pose.rotation[1] - 0.0).abs() < 0.000_001);
|
||||
assert!((pose.rotation[2] - 0.097_995_8).abs() < 0.000_01);
|
||||
assert!((pose.rotation[3] - 0.995_186_8).abs() < 0.000_01);
|
||||
assert!((rotated[0] + 0.195_048_3).abs() < 0.000_01);
|
||||
assert!((rotated[1] - 0.980_793_6).abs() < 0.000_01);
|
||||
assert!(rotated[2].abs() < 0.000_01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+1079
-35
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,11 @@
|
||||
//! reference stub until opcode timing, gates, RNG, and command-body semantics
|
||||
//! are backed by runtime-captured evidence.
|
||||
|
||||
pub mod atmosphere;
|
||||
pub mod environment;
|
||||
pub mod shadow;
|
||||
pub mod sky;
|
||||
|
||||
use fparkan_binary::{Cursor, DecodeError};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -85,8 +90,13 @@ pub struct FxCommand {
|
||||
pub word: u32,
|
||||
/// Decoded opcode.
|
||||
pub opcode: FxOpcode,
|
||||
/// Enabled bit.
|
||||
pub enabled: bool,
|
||||
/// Native command flag stored in bit 8 of the command word.
|
||||
///
|
||||
/// This flag is passed to the native command object as a mode/feature
|
||||
/// flag. It does not disable the command: real effects such as
|
||||
/// `env_lightning` use zero for all three command flags and still execute
|
||||
/// opcode 3, opcode 1, and opcode 2.
|
||||
pub native_flag: bool,
|
||||
/// Command body after the word.
|
||||
pub raw_body: Arc<[u8]>,
|
||||
/// Resource references discovered in known command layouts.
|
||||
@@ -314,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
|
||||
commands.push(FxCommand {
|
||||
word,
|
||||
opcode,
|
||||
enabled: ((word >> 8) & 1) != 0,
|
||||
native_flag: ((word >> 8) & 1) != 0,
|
||||
raw_body,
|
||||
resource_refs,
|
||||
});
|
||||
@@ -369,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
|
||||
return Ok(());
|
||||
}
|
||||
for (index, command) in state.document.commands.iter().enumerate() {
|
||||
if !command.enabled {
|
||||
continue;
|
||||
}
|
||||
let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
if command.opcode == FxOpcode::Op2 {
|
||||
out.push(FxEmission::Sound(FxSoundEvent { command_index }));
|
||||
@@ -605,7 +612,7 @@ mod tests {
|
||||
|
||||
assert_eq!(document.header().command_count, 2);
|
||||
assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
|
||||
assert!(document.commands()[0].enabled);
|
||||
assert!(document.commands()[0].native_flag);
|
||||
assert_eq!(
|
||||
document.commands()[0].resource_refs[0].archive_name(),
|
||||
b"sounds.lib"
|
||||
@@ -640,6 +647,25 @@ mod tests {
|
||||
assert!(document.commands()[0].raw_body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_native_flags_still_emit_every_command() {
|
||||
let mut bytes = header(3);
|
||||
bytes.extend_from_slice(&command(0x0003, 200));
|
||||
bytes.extend_from_slice(&command(0x0001, 224));
|
||||
bytes.extend_from_slice(&command(0x0002, 148));
|
||||
let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
|
||||
|
||||
assert!(document
|
||||
.commands()
|
||||
.iter()
|
||||
.all(|command| !command.native_flag));
|
||||
let state = create_instance(document, FxSeed(9), Transform::default()).expect("state");
|
||||
assert_eq!(
|
||||
canonical_emission_capture(&state).expect("capture"),
|
||||
b"P,0, Op3\nP,1, Op1\nS,2\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_opcode_at_command_index() {
|
||||
let mut bytes = header(1);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+685
-269
File diff suppressed because it is too large
Load Diff
+596
-64
@@ -18,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3;
|
||||
pub const STREAM_NORMALS: u32 = 4;
|
||||
/// Texture coordinate stream.
|
||||
pub const STREAM_UV0: u32 = 5;
|
||||
/// Optional secondary texture coordinate stream used by native lightmap/detail
|
||||
/// stages. MSH stores it as the type-18 four-byte packed UV stream.
|
||||
pub const STREAM_UV1: u32 = 18;
|
||||
/// Triangle index stream.
|
||||
pub const STREAM_INDICES: u32 = 6;
|
||||
/// Animation key stream.
|
||||
@@ -81,14 +84,20 @@ pub struct ModelAsset {
|
||||
pub node_count: usize,
|
||||
/// Raw node table.
|
||||
pub nodes_raw: Vec<u8>,
|
||||
/// Model-level bounding sphere center from the Res2 header.
|
||||
pub bounding_sphere_center: [f32; 3],
|
||||
/// Model-level bounding sphere radius from the Res2 header.
|
||||
pub bounding_sphere_radius: f32,
|
||||
/// Slot table.
|
||||
pub slots: Vec<Slot>,
|
||||
/// Vertex positions.
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Optional normals.
|
||||
pub normals: Option<Vec<[i8; 4]>>,
|
||||
/// Optional texture coordinates.
|
||||
pub uv0: Option<Vec<[i16; 2]>>,
|
||||
/// Optional primary packed texture coordinates from type 5.
|
||||
pub uv0: Option<Vec<[u16; 2]>>,
|
||||
/// Optional secondary packed texture coordinates from type 18.
|
||||
pub uv1: Option<Vec<[u16; 2]>>,
|
||||
/// Triangle indices.
|
||||
pub indices: Vec<u16>,
|
||||
/// Draw batches.
|
||||
@@ -167,18 +176,24 @@ pub struct Slot {
|
||||
pub struct Batch {
|
||||
/// Batch flags.
|
||||
pub batch_flags: u16,
|
||||
/// Material index.
|
||||
pub material_index: u16,
|
||||
/// Opaque field.
|
||||
pub opaque4: u16,
|
||||
/// Opaque field.
|
||||
pub opaque6: u16,
|
||||
/// High material selector/slot field at byte offset `+0x02`.
|
||||
///
|
||||
/// The ordinary WEAR material selector is the byte at `+0x04`. This
|
||||
/// field is retained separately because the native path can override it
|
||||
/// with a forced material index.
|
||||
pub material_index_hi: u16,
|
||||
/// WEAR material selector at byte offset `+0x04`.
|
||||
pub material_index: u8,
|
||||
/// Lightmap selector at byte offset `+0x05`.
|
||||
pub lightmap_index: u8,
|
||||
/// Local batch index at byte offset `+0x06`.
|
||||
pub local_batch_index: u16,
|
||||
/// Index count.
|
||||
pub index_count: u16,
|
||||
/// First index offset.
|
||||
pub index_start: u32,
|
||||
/// Opaque field.
|
||||
pub opaque14: u16,
|
||||
/// Vertex count at byte offset `+0x0E`.
|
||||
pub vertex_count: u16,
|
||||
/// Base vertex.
|
||||
pub base_vertex: u32,
|
||||
}
|
||||
@@ -194,8 +209,10 @@ pub struct VertexStreams {
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Optional normals.
|
||||
pub normals: Option<Vec<[i8; 4]>>,
|
||||
/// Optional texture coordinates.
|
||||
pub uv0: Option<Vec<[i16; 2]>>,
|
||||
/// Optional primary packed texture coordinates from type 5.
|
||||
pub uv0: Option<Vec<[u16; 2]>>,
|
||||
/// Optional secondary packed texture coordinates from type 18.
|
||||
pub uv1: Option<Vec<[u16; 2]>>,
|
||||
}
|
||||
|
||||
/// Preserved non-core stream.
|
||||
@@ -209,11 +226,11 @@ pub struct PreservedStream {
|
||||
pub bytes: std::sync::Arc<[u8]>,
|
||||
}
|
||||
|
||||
/// LOD id.
|
||||
/// Native state id kept under the historical `Lod` API name.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Lod(pub u8);
|
||||
|
||||
/// Group id.
|
||||
/// Native LOD id kept under the historical `Group` API name.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Group(pub u8);
|
||||
|
||||
@@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Returns the selected slot for a node/lod/group tuple.
|
||||
/// Returns the selected slot for a node/native-state/native-LOD tuple.
|
||||
///
|
||||
/// The argument names are retained for API compatibility: `Lod` is the
|
||||
/// three-value native state and `Group` is the five-value native LOD.
|
||||
#[must_use]
|
||||
pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> {
|
||||
if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
|
||||
@@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the source fallback pose of a standard node.
|
||||
/// Returns the fallback key as a native Node38 source-local pose.
|
||||
///
|
||||
/// The legacy sampler uses this key when a node has no frame map or its current
|
||||
/// frame falls outside that map. It is a static-pose input only; callers must
|
||||
/// still recover animation state and the meaning of `parent_or_link_raw` before
|
||||
/// assembling a runtime hierarchy.
|
||||
/// assembling a runtime hierarchy. Its quaternion is converted to match native
|
||||
/// AniMesh's quaternion-to-matrix convention.
|
||||
#[must_use]
|
||||
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
|
||||
let metadata = node38_metadata(model, node)?;
|
||||
@@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
|
||||
.keys
|
||||
.get(usize::from(metadata.fallback_key))
|
||||
.map(AnimKey24::sampling_pose)
|
||||
.map(node38_native_local_pose)
|
||||
}
|
||||
|
||||
/// Converts a sampled MSH source-local quaternion into the native AniMesh
|
||||
/// quaternion-matrix convention before Node38 hierarchy composition. Native
|
||||
/// AniMesh's matrix coefficients are the transpose of the portable `Pose`
|
||||
/// basis.
|
||||
fn node38_native_local_pose(mut pose: Pose) -> Pose {
|
||||
pose.rotation[0] = -pose.rotation[0];
|
||||
pose.rotation[1] = -pose.rotation[1];
|
||||
pose.rotation[2] = -pose.rotation[2];
|
||||
pose
|
||||
}
|
||||
|
||||
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
|
||||
@@ -462,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
||||
evaluate_hierarchy(&parents, &poses).ok()
|
||||
}
|
||||
|
||||
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
|
||||
/// model at one explicitly supplied logical frame.
|
||||
/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical
|
||||
/// frame, using portable key sampling and native AniMesh local-matrix
|
||||
/// convention.
|
||||
///
|
||||
/// A node without a usable type-19 map, or a requested frame outside the
|
||||
/// declared map length, retains its exact fallback key. Mapped keys are
|
||||
@@ -475,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
||||
/// representation or the map cannot be safely resolved.
|
||||
#[must_use]
|
||||
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
|
||||
node38_sampled_hierarchy_with_selector(model, |_| {
|
||||
Some((u32::from(frame), AnimationTime(f32::from(frame))))
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluates a standard `Node38` hierarchy with an independent floating-point
|
||||
/// sample time for each node.
|
||||
///
|
||||
/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19
|
||||
/// frame-map selector, then samples the selected key and its immediate
|
||||
/// successor at the original fractional time. This matches the native
|
||||
/// sampler's selector calculation while keeping interpolation time fractional.
|
||||
/// The selector uses Rust's default-nearest, ties-to-even rounding. This is
|
||||
/// the portable counterpart of the native x87 conversion under its default
|
||||
/// rounding mode; it does not claim parity under every x87 control-word mode.
|
||||
/// A missing time uses that node's exact fallback key. Supplied times must be
|
||||
/// finite and non-negative; the caller owns choosing the per-node times.
|
||||
///
|
||||
/// Returns `None` when the model is not a complete standard-node animation,
|
||||
/// the slice does not match the node count, any supplied time is invalid, or a
|
||||
/// frame-map entry cannot be safely resolved.
|
||||
#[must_use]
|
||||
pub fn node38_sampled_hierarchy_at_times(
|
||||
model: &ModelAsset,
|
||||
node_times: &[Option<AnimationTime>],
|
||||
) -> Option<NodePoseBuffer> {
|
||||
if node_times.len() != model.node_count
|
||||
|| node_times
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|time| !time.0.is_finite() || time.0 < 0.0)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
node38_sampled_hierarchy_with_selector(model, |index| {
|
||||
let time = node_times[index]?;
|
||||
let selector = (time.0 - 0.5).round_ties_even();
|
||||
// A finite, non-negative time can still be beyond the representable
|
||||
// map range. Saturate to a frame that will resolve to the node's
|
||||
// fallback key rather than allowing a float-to-index wraparound.
|
||||
let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 {
|
||||
u32::MAX
|
||||
} else {
|
||||
selector as u32
|
||||
};
|
||||
Some((map_frame, time))
|
||||
})
|
||||
}
|
||||
|
||||
fn node38_sampled_hierarchy_with_selector(
|
||||
model: &ModelAsset,
|
||||
mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>,
|
||||
) -> Option<NodePoseBuffer> {
|
||||
if model.node_stride != 38 || model.node_count == 0 {
|
||||
return None;
|
||||
}
|
||||
@@ -492,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePo
|
||||
};
|
||||
let fallback_index = usize::from(metadata.fallback_key);
|
||||
let _fallback = animation.keys.get(fallback_index)?;
|
||||
let key_index =
|
||||
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
|
||||
let sample = selector_for_node(index);
|
||||
let key_index = if let Some((map_frame, _)) = sample {
|
||||
if metadata.anim_map_start == u16::MAX
|
||||
|| map_frame == u32::MAX
|
||||
|| map_frame >= animation.frame_count
|
||||
{
|
||||
fallback_index
|
||||
} else {
|
||||
let mapped_index =
|
||||
usize::from(*animation.frame_map.get(
|
||||
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
|
||||
)?);
|
||||
let mapped_index = usize::from(
|
||||
*animation.frame_map.get(
|
||||
usize::from(metadata.anim_map_start)
|
||||
.checked_add(usize::try_from(map_frame).ok()?)?,
|
||||
)?,
|
||||
);
|
||||
if mapped_index < fallback_index {
|
||||
mapped_index
|
||||
} else {
|
||||
fallback_index
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallback_index
|
||||
};
|
||||
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
|
||||
let sample_time = sample.map_or(AnimationTime(0.0), |(_, time)| time);
|
||||
let pose = node38_native_local_pose(sample_node38_key_pair(
|
||||
&animation.keys,
|
||||
key_index,
|
||||
fallback_index,
|
||||
sample_time,
|
||||
)?);
|
||||
parents.push(parent);
|
||||
poses.push(pose);
|
||||
}
|
||||
@@ -517,7 +619,7 @@ fn sample_node38_key_pair(
|
||||
keys: &[AnimKey24],
|
||||
key_index: usize,
|
||||
fallback_index: usize,
|
||||
frame: u16,
|
||||
sample_time: AnimationTime,
|
||||
) -> Option<Pose> {
|
||||
let key = *keys.get(key_index)?;
|
||||
if key_index == fallback_index {
|
||||
@@ -541,7 +643,7 @@ fn sample_node38_key_pair(
|
||||
],
|
||||
)
|
||||
.ok()?;
|
||||
track.sample(AnimationTime(f32::from(frame))).ok()
|
||||
track.sample(sample_time).ok()
|
||||
}
|
||||
|
||||
/// Returns draw batches for a validated slot.
|
||||
@@ -667,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
||||
}
|
||||
let node_count = nodes_stream.bytes.len() / node_stride;
|
||||
|
||||
let (bounding_sphere_center, bounding_sphere_radius) =
|
||||
parse_res2_bounding_sphere(&slots_stream.bytes)?;
|
||||
let slots = parse_slots(&slots_stream.bytes)?;
|
||||
let positions = parse_positions(&positions_stream.bytes)?;
|
||||
let indices = parse_u16_array(&indices_stream.bytes, "Res6")?;
|
||||
@@ -678,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
||||
.map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
|
||||
.transpose()?;
|
||||
let uv0 = read_optional_stream(document, STREAM_UV0)?
|
||||
.map(|raw| parse_i16x2_array(&raw.bytes, "Res5"))
|
||||
.map(|raw| parse_u16x2_array(&raw.bytes, "Res5"))
|
||||
.transpose()?;
|
||||
let uv1 = read_optional_stream(document, STREAM_UV1)?
|
||||
.map(|raw| parse_secondary_uv_stream(raw, positions.len()))
|
||||
.transpose()?;
|
||||
let node_names = read_optional_stream(document, STREAM_NAMES)?
|
||||
.map(|raw| parse_res10_names(&raw.bytes, node_count))
|
||||
@@ -689,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
||||
node_stride,
|
||||
node_count,
|
||||
nodes_raw: nodes_stream.bytes,
|
||||
bounding_sphere_center,
|
||||
bounding_sphere_radius,
|
||||
slots,
|
||||
positions,
|
||||
normals,
|
||||
uv0,
|
||||
uv1,
|
||||
indices,
|
||||
batches,
|
||||
node_names,
|
||||
@@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
|
||||
Ok(slots)
|
||||
}
|
||||
|
||||
fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> {
|
||||
if data.len() < 0x8C {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"invalid Res2 size: {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
let center = [
|
||||
read_f32(data, 0x60)?,
|
||||
read_f32(data, 0x64)?,
|
||||
read_f32(data, 0x68)?,
|
||||
];
|
||||
let radius = read_f32(data, 0x6C)?;
|
||||
if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 {
|
||||
return Err(MshError::InvalidGeometry(
|
||||
"invalid Res2 bounding sphere".to_string(),
|
||||
));
|
||||
}
|
||||
Ok((center, radius))
|
||||
}
|
||||
|
||||
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
|
||||
if !data.len().is_multiple_of(12) {
|
||||
return Err(invalid_resource_size("Res3", data.len(), 12));
|
||||
@@ -845,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
|
||||
for offset in (0..data.len()).step_by(20) {
|
||||
out.push(Batch {
|
||||
batch_flags: read_u16_required(data, offset)?,
|
||||
material_index: read_u16_required(data, offset + 2)?,
|
||||
opaque4: read_u16_required(data, offset + 4)?,
|
||||
opaque6: read_u16_required(data, offset + 6)?,
|
||||
material_index_hi: read_u16_required(data, offset + 2)?,
|
||||
material_index: *data
|
||||
.get(offset + 4)
|
||||
.ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?,
|
||||
lightmap_index: *data
|
||||
.get(offset + 5)
|
||||
.ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?,
|
||||
local_batch_index: read_u16_required(data, offset + 6)?,
|
||||
index_count: read_u16_required(data, offset + 8)?,
|
||||
index_start: read_u32(data, offset + 10)?,
|
||||
opaque14: read_u16_required(data, offset + 14)?,
|
||||
vertex_count: read_u16_required(data, offset + 14)?,
|
||||
base_vertex: read_u32(data, offset + 16)?,
|
||||
});
|
||||
}
|
||||
@@ -884,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, Ms
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
|
||||
fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, MshError> {
|
||||
if !data.len().is_multiple_of(4) {
|
||||
return Err(invalid_resource_size(label, data.len(), 4));
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len() / 4);
|
||||
for offset in (0..data.len()).step_by(4) {
|
||||
out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]);
|
||||
out.push([
|
||||
read_u16_required(data, offset)?,
|
||||
read_u16_required(data, offset + 2)?,
|
||||
]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_secondary_uv_stream(
|
||||
stream: RawStream,
|
||||
position_count: usize,
|
||||
) -> Result<Vec<[u16; 2]>, MshError> {
|
||||
if stream.attributes.attr3 != 4 {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"invalid Res18 stride: expected 4, got {}",
|
||||
stream.attributes.attr3
|
||||
)));
|
||||
}
|
||||
let values = parse_u16x2_array(&stream.bytes, "Res18")?;
|
||||
let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| {
|
||||
MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string())
|
||||
})?;
|
||||
if declared_count != values.len() {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"Res18 vertex count does not match payload: declared={}, decoded={}",
|
||||
declared_count,
|
||||
values.len()
|
||||
)));
|
||||
}
|
||||
if values.len() != position_count {
|
||||
return Err(MshError::InvalidGeometry(format!(
|
||||
"Res18 vertex count does not match Res3: uv1={}, positions={}",
|
||||
values.len(),
|
||||
position_count
|
||||
)));
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
|
||||
let mut out = Vec::with_capacity(node_count);
|
||||
let mut offset = 0usize;
|
||||
@@ -1038,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
|
||||
Ok(u16::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
|
||||
let raw = bytes
|
||||
.get(offset..offset.saturating_add(2))
|
||||
.ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
|
||||
let arr: [u8; 2] = raw
|
||||
.try_into()
|
||||
.map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
|
||||
Ok(i16::from_le_bytes(arr))
|
||||
}
|
||||
|
||||
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
|
||||
let byte = bytes
|
||||
.get(offset)
|
||||
@@ -1309,18 +1469,21 @@ mod tests {
|
||||
let mut nodes = root;
|
||||
nodes.extend(child);
|
||||
let mut keys = Vec::new();
|
||||
for (x, y, z, qz, qw) in [
|
||||
// Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW.
|
||||
// The native Node38 local-basis conversion happens before hierarchy
|
||||
// evaluation, not in the generic animation decoder.
|
||||
for (x, y, z, qw, qz) in [
|
||||
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
|
||||
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
|
||||
(2.0, 0.0, 0.0, 32_767_i16, 0_i16),
|
||||
] {
|
||||
push_f32(&mut keys, x);
|
||||
push_f32(&mut keys, y);
|
||||
push_f32(&mut keys, z);
|
||||
push_f32(&mut keys, 0.0);
|
||||
push_u16(&mut keys, qw.cast_unsigned());
|
||||
push_u16(&mut keys, 0);
|
||||
push_u16(&mut keys, 0);
|
||||
push_u16(&mut keys, qz.cast_unsigned());
|
||||
push_u16(&mut keys, qw.cast_unsigned());
|
||||
}
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
|
||||
@@ -1336,7 +1499,78 @@ mod tests {
|
||||
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
|
||||
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
|
||||
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
|
||||
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
|
||||
assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() {
|
||||
let mut root = node38([u16::MAX; 15]);
|
||||
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
|
||||
root[6..8].copy_from_slice(&1_u16.to_le_bytes());
|
||||
let mut child = node38([u16::MAX; 15]);
|
||||
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
|
||||
child[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
child[6..8].copy_from_slice(&2_u16.to_le_bytes());
|
||||
let mut nodes = root;
|
||||
nodes.extend(child);
|
||||
let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two];
|
||||
let identity = [0.0, 0.0, 0.0, 1.0];
|
||||
let child_translation = [0.0, -1.01209, -0.001792];
|
||||
let model = ModelAsset {
|
||||
node_stride: 38,
|
||||
node_count: 2,
|
||||
nodes_raw: nodes,
|
||||
bounding_sphere_center: [0.0; 3],
|
||||
bounding_sphere_radius: 1.0,
|
||||
slots: Vec::new(),
|
||||
positions: Vec::new(),
|
||||
normals: None,
|
||||
uv0: None,
|
||||
uv1: None,
|
||||
indices: Vec::new(),
|
||||
batches: Vec::new(),
|
||||
node_names: None,
|
||||
animation: Some(ModelAnimation {
|
||||
keys: vec![
|
||||
AnimKey24 {
|
||||
time: AnimationTime(0.0),
|
||||
pose: Pose {
|
||||
translation: [0.0; 3],
|
||||
rotation: root_rotation,
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(1.0),
|
||||
pose: Pose {
|
||||
translation: [0.0; 3],
|
||||
rotation: root_rotation,
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(0.0),
|
||||
pose: Pose {
|
||||
translation: child_translation,
|
||||
rotation: identity,
|
||||
},
|
||||
},
|
||||
],
|
||||
frame_map: vec![0],
|
||||
frame_count: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose");
|
||||
assert!(fallback_root.rotation[0] < -0.7);
|
||||
let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy");
|
||||
let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy");
|
||||
for hierarchy in [&fallback, &sampled] {
|
||||
let child_translation = hierarchy.poses[1].translation;
|
||||
assert!(child_translation[0].abs() < 0.0001);
|
||||
assert!((child_translation[1] + 0.001792).abs() < 0.0001);
|
||||
assert!((child_translation[2] - 1.01209).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1349,10 +1583,13 @@ mod tests {
|
||||
node_stride: 38,
|
||||
node_count: 1,
|
||||
nodes_raw: node,
|
||||
bounding_sphere_center: [0.0; 3],
|
||||
bounding_sphere_radius: 1.0,
|
||||
slots: Vec::new(),
|
||||
positions: Vec::new(),
|
||||
normals: None,
|
||||
uv0: None,
|
||||
uv1: None,
|
||||
indices: Vec::new(),
|
||||
batches: Vec::new(),
|
||||
node_names: None,
|
||||
@@ -1394,6 +1631,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() {
|
||||
let mut root = node38([u16::MAX; 15]);
|
||||
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
root[4..6].copy_from_slice(&0_u16.to_le_bytes());
|
||||
root[6..8].copy_from_slice(&4_u16.to_le_bytes());
|
||||
let mut child = node38([u16::MAX; 15]);
|
||||
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
|
||||
child[4..6].copy_from_slice(&2_u16.to_le_bytes());
|
||||
child[6..8].copy_from_slice(&5_u16.to_le_bytes());
|
||||
let mut nodes = root;
|
||||
nodes.extend(child);
|
||||
let model = ModelAsset {
|
||||
node_stride: 38,
|
||||
node_count: 2,
|
||||
nodes_raw: nodes,
|
||||
bounding_sphere_center: [0.0; 3],
|
||||
bounding_sphere_radius: 1.0,
|
||||
slots: Vec::new(),
|
||||
positions: Vec::new(),
|
||||
normals: None,
|
||||
uv0: None,
|
||||
uv1: None,
|
||||
indices: Vec::new(),
|
||||
batches: Vec::new(),
|
||||
node_names: None,
|
||||
animation: Some(ModelAnimation {
|
||||
keys: vec![
|
||||
AnimKey24 {
|
||||
time: AnimationTime(0.0),
|
||||
pose: Pose {
|
||||
translation: [4.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(2.0),
|
||||
pose: Pose {
|
||||
translation: [8.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(1.0),
|
||||
pose: Pose {
|
||||
translation: [20.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(2.0),
|
||||
pose: Pose {
|
||||
translation: [30.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(9.0),
|
||||
pose: Pose {
|
||||
translation: [100.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(9.0),
|
||||
pose: Pose {
|
||||
translation: [300.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
],
|
||||
frame_map: vec![0, 1, 0, 2],
|
||||
frame_count: 4,
|
||||
}),
|
||||
};
|
||||
|
||||
let hierarchy = node38_sampled_hierarchy_at_times(
|
||||
&model,
|
||||
&[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))],
|
||||
)
|
||||
.expect("per-node fractional hierarchy");
|
||||
// The root's tie selects map frame zero and interpolates to x=6. The
|
||||
// child independently selects frame one and samples its own 1.55 time;
|
||||
// its global pose includes the root translation.
|
||||
assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON);
|
||||
assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001);
|
||||
let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None])
|
||||
.expect("per-node fallback hierarchy");
|
||||
assert_eq!(fallback.poses[1].translation[0], 306.0);
|
||||
assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none());
|
||||
for invalid in [-1.0, f32::NAN, f32::INFINITY] {
|
||||
assert!(node38_sampled_hierarchy_at_times(
|
||||
&model,
|
||||
&[Some(AnimationTime(invalid)), None]
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||
let too_small = decode_nested(&build_nres(&[
|
||||
@@ -1420,6 +1756,44 @@ mod tests {
|
||||
assert!(matches!(err, MshError::InvalidGeometry(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() {
|
||||
let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]);
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &valid_slots),
|
||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
]))
|
||||
.expect("nested");
|
||||
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
|
||||
assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]);
|
||||
assert_eq!(model.bounding_sphere_radius, 4.5);
|
||||
|
||||
for (offset, value) in [
|
||||
(0x60, f32::NAN),
|
||||
(0x68, f32::INFINITY),
|
||||
(0x6C, f32::NAN),
|
||||
(0x6C, -1.0),
|
||||
] {
|
||||
let mut slots = slots_payload(&[]);
|
||||
slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &slots),
|
||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
]))
|
||||
.expect("nested");
|
||||
assert!(matches!(
|
||||
validate_msh(&decode_msh(&document).expect("msh")),
|
||||
Err(MshError::InvalidGeometry(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_batch_range_out_of_bounds_is_error() {
|
||||
let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
|
||||
@@ -1448,6 +1822,7 @@ mod tests {
|
||||
(STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
|
||||
(STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
|
||||
(STREAM_UV0, b"Res5".as_slice(), vec![0; 3]),
|
||||
(STREAM_UV1, b"Res18".as_slice(), vec![0; 3]),
|
||||
(STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
|
||||
] {
|
||||
let slots = slots_payload(&[]);
|
||||
@@ -1471,6 +1846,127 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() {
|
||||
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]);
|
||||
let mut uv1 = Vec::new();
|
||||
for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] {
|
||||
push_u16(&mut uv1, pair[0]);
|
||||
push_u16(&mut uv1, pair[1]);
|
||||
}
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
||||
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1),
|
||||
]))
|
||||
.expect("nested");
|
||||
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
|
||||
|
||||
assert_eq!(model.positions.len(), 4);
|
||||
assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type18_secondary_uv_rejects_untrusted_count_or_stride() {
|
||||
let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]);
|
||||
for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] {
|
||||
let mut uv1 = Vec::new();
|
||||
push_u16(&mut uv1, 2);
|
||||
push_u16(&mut uv1, 5);
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
||||
stream(STREAM_POSITIONS, 12, b"Res3", &positions),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1),
|
||||
]))
|
||||
.expect("nested");
|
||||
let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid");
|
||||
let message = err.to_string();
|
||||
assert!(message.contains(expected), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_gog_secondary_uv_fixture_is_aligned_and_real() {
|
||||
let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else {
|
||||
return;
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
assert!(
|
||||
root.is_dir(),
|
||||
"FPARKAN_GAME_ROOT is missing: {}",
|
||||
root.display()
|
||||
);
|
||||
|
||||
let mut models_with_uv1 = 0usize;
|
||||
let mut fixture_found = false;
|
||||
for path in files_under(&root) {
|
||||
if !path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("rlb"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(archive) =
|
||||
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for entry in archive
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|entry| has_msh_extension(entry.name_bytes()))
|
||||
{
|
||||
let payload = archive.payload(entry.id()).expect("payload");
|
||||
let nested = fparkan_nres::decode(
|
||||
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
|
||||
let msh = decode_msh(&nested)
|
||||
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
|
||||
let model = validate_msh(&msh)
|
||||
.unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
|
||||
let Some(uv1) = model.uv1.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
models_with_uv1 += 1;
|
||||
assert_eq!(
|
||||
uv1.len(),
|
||||
model.positions.len(),
|
||||
"{path:?} {:?}",
|
||||
entry.name_bytes()
|
||||
);
|
||||
let descriptor = msh
|
||||
.streams()
|
||||
.iter()
|
||||
.find(|stream| stream.type_id == STREAM_UV1)
|
||||
.expect("Res18 descriptor");
|
||||
assert_eq!(descriptor.attributes.attr1 as usize, uv1.len());
|
||||
assert_eq!(descriptor.attributes.attr3, 4);
|
||||
|
||||
if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") {
|
||||
assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]);
|
||||
fixture_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH");
|
||||
assert!(
|
||||
fixture_found,
|
||||
"FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch20_uses_unaligned_field_offsets() {
|
||||
let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
|
||||
@@ -1488,12 +1984,13 @@ mod tests {
|
||||
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
|
||||
|
||||
assert_eq!(model.batches[0].batch_flags, 0x1100);
|
||||
assert_eq!(model.batches[0].material_index, 0x2200);
|
||||
assert_eq!(model.batches[0].opaque4, 0x3300);
|
||||
assert_eq!(model.batches[0].opaque6, 0x4400);
|
||||
assert_eq!(model.batches[0].material_index_hi, 0x2200);
|
||||
assert_eq!(model.batches[0].material_index, 0x00);
|
||||
assert_eq!(model.batches[0].lightmap_index, 0x33);
|
||||
assert_eq!(model.batches[0].local_batch_index, 0x4400);
|
||||
assert_eq!(model.batches[0].index_count, 1);
|
||||
assert_eq!(model.batches[0].index_start, 0);
|
||||
assert_eq!(model.batches[0].opaque14, 0x5500);
|
||||
assert_eq!(model.batches[0].vertex_count, 0x5500);
|
||||
assert_eq!(model.batches[0].base_vertex, 0);
|
||||
}
|
||||
|
||||
@@ -1587,18 +2084,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_corpus_msh_assets_validate() {
|
||||
for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
|
||||
fn licensed_corpus_part1_msh_assets_validate() {
|
||||
validate_licensed_corpus_msh_assets("IS", 435);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_corpus_part2_msh_assets_validate() {
|
||||
validate_licensed_corpus_msh_assets("IS2", 511);
|
||||
}
|
||||
|
||||
fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) {
|
||||
let root = corpus_root(corpus);
|
||||
let mut count = 0usize;
|
||||
for path in files_under(&root) {
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(archive) = fparkan_nres::decode(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
) else {
|
||||
let Ok(archive) =
|
||||
fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for entry in archive
|
||||
@@ -1611,9 +2116,7 @@ mod tests {
|
||||
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.unwrap_or_else(|err| {
|
||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||
});
|
||||
.unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()));
|
||||
let msh = decode_msh(&nested).unwrap_or_else(|err| {
|
||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||
});
|
||||
@@ -1625,7 +2128,6 @@ mod tests {
|
||||
}
|
||||
assert_eq!(count, expected, "{corpus} MSH count");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
@@ -1932,6 +2434,23 @@ mod tests {
|
||||
fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> {
|
||||
TestEntry {
|
||||
type_id,
|
||||
attr1: 0,
|
||||
attr3,
|
||||
name,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_with_attr1<'a>(
|
||||
type_id: u32,
|
||||
attr1: u32,
|
||||
attr3: u32,
|
||||
name: &'a [u8],
|
||||
payload: &'a [u8],
|
||||
) -> TestEntry<'a> {
|
||||
TestEntry {
|
||||
type_id,
|
||||
attr1,
|
||||
attr3,
|
||||
name,
|
||||
payload,
|
||||
@@ -1940,6 +2459,7 @@ mod tests {
|
||||
|
||||
struct TestEntry<'a> {
|
||||
type_id: u32,
|
||||
attr1: u32,
|
||||
attr3: u32,
|
||||
name: &'a [u8],
|
||||
payload: &'a [u8],
|
||||
@@ -1958,7 +2478,7 @@ mod tests {
|
||||
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
push_u32(&mut out, entry.type_id);
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(&mut out, entry.attr1);
|
||||
push_u32(&mut out, 0);
|
||||
push_u32(
|
||||
&mut out,
|
||||
@@ -2000,7 +2520,19 @@ mod tests {
|
||||
}
|
||||
|
||||
fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> {
|
||||
slots_payload_with_bounds([0.0; 3], 1.0, records)
|
||||
}
|
||||
|
||||
fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = vec![0; 0x8c];
|
||||
for (offset, value) in [
|
||||
(0x60, center[0]),
|
||||
(0x64, center[1]),
|
||||
(0x68, center[2]),
|
||||
(0x6C, radius),
|
||||
] {
|
||||
out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
for record in records {
|
||||
assert_eq!(record.len(), 68);
|
||||
out.extend_from_slice(record);
|
||||
|
||||
@@ -15,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48;
|
||||
const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
|
||||
const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32;
|
||||
|
||||
/// `objects.rlb` external geometry entry type (`EXTO`).
|
||||
pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO");
|
||||
|
||||
/// Prototype key.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct PrototypeKey(pub ResourceName);
|
||||
@@ -61,10 +64,21 @@ pub struct UnitComponentRecord {
|
||||
pub parent_or_link: i32,
|
||||
/// Description raw bytes.
|
||||
pub description_raw: [u8; 32],
|
||||
/// Opaque tail.
|
||||
/// Opaque record word at offset `0x68`.
|
||||
pub tail0: u32,
|
||||
/// Opaque tail.
|
||||
pub tail1: u32,
|
||||
/// Number of immediate children in the preorder unit tree.
|
||||
pub immediate_child_count: u32,
|
||||
}
|
||||
|
||||
/// One node in a decoded Unit DAT preorder tree.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UnitComponentTreeNode {
|
||||
/// Original preorder record index.
|
||||
pub record_index: usize,
|
||||
/// Parent record index, or `None` for the root.
|
||||
pub parent_index: Option<usize>,
|
||||
/// Direct child record indices in source order.
|
||||
pub children: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Prototype geometry.
|
||||
@@ -85,8 +99,18 @@ pub struct EffectivePrototype {
|
||||
pub geometry: PrototypeGeometry,
|
||||
/// Resolution source.
|
||||
pub source: PrototypeSource,
|
||||
/// Source registry entry type, when the prototype came from an NRes
|
||||
/// object registry (for example `EXTO`, `INTO`, or `BTLU`).
|
||||
pub source_type: Option<u32>,
|
||||
/// Resource dependencies discovered while resolving this prototype.
|
||||
pub dependencies: Vec<ResourceKey>,
|
||||
/// Explicit control-data resource referenced by this prototype's resolved
|
||||
/// `objects.rlb` registry inheritance chain, when present.
|
||||
///
|
||||
/// This is deliberately separate from visual dependencies: controller
|
||||
/// state belongs to each component instance and must not alter shared
|
||||
/// mesh/material cache identity.
|
||||
pub control_resource: Option<ResourceKey>,
|
||||
}
|
||||
|
||||
/// Prototype resolution source.
|
||||
@@ -401,6 +425,8 @@ pub enum PrototypeError {
|
||||
InvalidSize,
|
||||
/// Invalid unit DAT magic.
|
||||
InvalidUnitDatMagic(u32),
|
||||
/// Unit DAT child counts do not describe one complete preorder tree.
|
||||
InvalidUnitHierarchy(String),
|
||||
/// Invalid path.
|
||||
InvalidPath(String),
|
||||
/// VFS error.
|
||||
@@ -435,6 +461,9 @@ impl std::fmt::Display for PrototypeError {
|
||||
Self::InvalidUnitDatMagic(magic) => {
|
||||
write!(f, "invalid unit DAT magic: {magic:#010X}")
|
||||
}
|
||||
Self::InvalidUnitHierarchy(message) => {
|
||||
write!(f, "invalid unit DAT preorder hierarchy: {message}")
|
||||
}
|
||||
Self::InvalidPath(value) => write!(f, "invalid path: {value}"),
|
||||
Self::Vfs(source) => write!(f, "vfs error: {source}"),
|
||||
Self::Resource(source) => write!(f, "resource error: {source}"),
|
||||
@@ -446,7 +475,10 @@ impl std::error::Error for PrototypeError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Decode(source) => Some(source),
|
||||
Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None,
|
||||
Self::InvalidSize
|
||||
| Self::InvalidUnitDatMagic(_)
|
||||
| Self::InvalidUnitHierarchy(_)
|
||||
| Self::InvalidPath(_) => None,
|
||||
Self::Vfs(source) => Some(source),
|
||||
Self::Resource(source) => Some(source),
|
||||
}
|
||||
@@ -512,7 +544,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
|
||||
let parent_or_link = cursor.read_i32_le()?;
|
||||
description_raw.copy_from_slice(cursor.read_exact(32)?);
|
||||
let tail0 = cursor.read_u32_le()?;
|
||||
let tail1 = cursor.read_u32_le()?;
|
||||
let immediate_child_count = cursor.read_u32_le()?;
|
||||
records.push(UnitComponentRecord {
|
||||
archive_raw,
|
||||
resource_raw,
|
||||
@@ -520,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
|
||||
parent_or_link,
|
||||
description_raw,
|
||||
tail0,
|
||||
tail1,
|
||||
immediate_child_count,
|
||||
});
|
||||
}
|
||||
cursor.require_eof()?;
|
||||
@@ -530,6 +562,86 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the immediate-child tree encoded by Unit DAT record counts.
|
||||
///
|
||||
/// Records are stored in preorder. The final word of each 112-byte record is
|
||||
/// the number of immediate children, so the tree can be reconstructed without
|
||||
/// treating `parent_or_link` as a parent index. That field is retained as the
|
||||
/// native mount-socket value for attachment consumers.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PrototypeError::InvalidUnitHierarchy`] when child counts consume
|
||||
/// too few or too many records.
|
||||
pub fn unit_component_tree(
|
||||
records: &[UnitComponentRecord],
|
||||
) -> Result<Vec<UnitComponentTreeNode>, PrototypeError> {
|
||||
if records.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let root_child_count = usize::try_from(records[0].immediate_child_count).map_err(|_| {
|
||||
PrototypeError::InvalidUnitHierarchy("record 0 child count does not fit usize".to_string())
|
||||
})?;
|
||||
let mut nodes = Vec::with_capacity(records.len());
|
||||
nodes.push(UnitComponentTreeNode {
|
||||
record_index: 0,
|
||||
parent_index: None,
|
||||
children: Vec::new(),
|
||||
});
|
||||
// Each open frame holds a preorder node and the direct children still due.
|
||||
// This keeps hostile deep chains off the call stack.
|
||||
let mut open_parents = vec![(0_usize, root_child_count)];
|
||||
let mut cursor = 1_usize;
|
||||
while cursor < records.len() {
|
||||
while open_parents
|
||||
.last()
|
||||
.is_some_and(|(_, remaining_children)| *remaining_children == 0)
|
||||
{
|
||||
open_parents.pop();
|
||||
}
|
||||
let Some((parent_node_index, remaining_children)) = open_parents.last_mut() else {
|
||||
return Err(PrototypeError::InvalidUnitHierarchy(format!(
|
||||
"root tree consumed {cursor} records, but {} were encoded",
|
||||
records.len()
|
||||
)));
|
||||
};
|
||||
let parent_node_index = *parent_node_index;
|
||||
*remaining_children -= 1;
|
||||
|
||||
let record_index = cursor;
|
||||
let record = &records[record_index];
|
||||
let child_count = usize::try_from(record.immediate_child_count).map_err(|_| {
|
||||
PrototypeError::InvalidUnitHierarchy(format!(
|
||||
"record {record_index} child count does not fit usize"
|
||||
))
|
||||
})?;
|
||||
let node_index = nodes.len();
|
||||
nodes.push(UnitComponentTreeNode {
|
||||
record_index,
|
||||
parent_index: Some(nodes[parent_node_index].record_index),
|
||||
children: Vec::new(),
|
||||
});
|
||||
nodes[parent_node_index].children.push(node_index);
|
||||
cursor += 1;
|
||||
open_parents.push((node_index, child_count));
|
||||
}
|
||||
|
||||
while open_parents
|
||||
.last()
|
||||
.is_some_and(|(_, remaining_children)| *remaining_children == 0)
|
||||
{
|
||||
open_parents.pop();
|
||||
}
|
||||
if !open_parents.is_empty() {
|
||||
return Err(PrototypeError::InvalidUnitHierarchy(format!(
|
||||
"child count reached record {cursor}, but only {} records exist",
|
||||
records.len()
|
||||
)));
|
||||
}
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Decodes a mission unit DAT binding.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -1029,7 +1141,7 @@ fn resolve_archive_model(
|
||||
let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(effective(model_key.clone(), mesh, source)))
|
||||
Ok(Some(effective(model_key.clone(), mesh, source, None, None)))
|
||||
}
|
||||
|
||||
fn resolve_objects_registry_model(
|
||||
@@ -1037,17 +1149,33 @@ fn resolve_objects_registry_model(
|
||||
registry_archive: &NormalizedPath,
|
||||
object_key: &ResourceName,
|
||||
) -> Result<Option<EffectivePrototype>, PrototypeError> {
|
||||
let source_type = match repository.open_archive(registry_archive) {
|
||||
Ok(archive_id) => {
|
||||
find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))?
|
||||
.map(|(entry, _)| repository.entry_info(entry))
|
||||
.transpose()?
|
||||
.and_then(|info| info.key.type_id)
|
||||
}
|
||||
Err(ResourceError::MissingArchive { .. }) => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(refs) =
|
||||
collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let control_resource = resolve_explicit_control_resource(object_key, &refs)?;
|
||||
|
||||
let mut missing_mesh_refs = Vec::new();
|
||||
for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
|
||||
if let Some(prototype) =
|
||||
resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))?
|
||||
{
|
||||
if let Some(prototype) = resolve_object_ref_model(
|
||||
repository,
|
||||
object_key,
|
||||
item,
|
||||
cstr_bytes(&item.resource_raw),
|
||||
source_type,
|
||||
control_resource.clone(),
|
||||
)? {
|
||||
return Ok(Some(prototype));
|
||||
}
|
||||
missing_mesh_refs.push(describe_object_ref(item));
|
||||
@@ -1064,7 +1192,9 @@ fn resolve_objects_registry_model(
|
||||
key: PrototypeKey(object_key.clone()),
|
||||
geometry: PrototypeGeometry::NonGeometric,
|
||||
source: PrototypeSource::ObjectsRegistry,
|
||||
source_type,
|
||||
dependencies: Vec::new(),
|
||||
control_resource,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1130,6 +1260,8 @@ fn resolve_object_ref_model(
|
||||
requested: &ResourceName,
|
||||
item: &ObjectRefRecord,
|
||||
model_name: &[u8],
|
||||
source_type: Option<u32>,
|
||||
control_resource: Option<ResourceKey>,
|
||||
) -> Result<Option<EffectivePrototype>, PrototypeError> {
|
||||
let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
|
||||
let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))?
|
||||
@@ -1140,6 +1272,8 @@ fn resolve_object_ref_model(
|
||||
requested.clone(),
|
||||
mesh,
|
||||
PrototypeSource::ObjectsRegistry,
|
||||
source_type,
|
||||
control_resource,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -1147,6 +1281,61 @@ fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool {
|
||||
has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh")
|
||||
}
|
||||
|
||||
fn is_explicit_control_ref(item: &ObjectRefRecord) -> bool {
|
||||
has_extension_bytes(cstr_bytes(&item.resource_raw), b"ctl")
|
||||
}
|
||||
|
||||
fn resolve_explicit_control_resource(
|
||||
requested: &ResourceName,
|
||||
refs: &[ObjectRefRecord],
|
||||
) -> Result<Option<ResourceKey>, PrototypeError> {
|
||||
let mut resolved = Vec::new();
|
||||
for item in refs.iter().filter(|item| is_explicit_control_ref(item)) {
|
||||
let archive =
|
||||
normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
|
||||
let name = resource_name(cstr_bytes(&item.resource_raw));
|
||||
// CTLD is an optional pose sidecar. Keep the exact registry reference
|
||||
// here, then let asset preparation treat a missing archive/entry as an
|
||||
// absent override while still rejecting a present malformed payload.
|
||||
let key = ResourceKey {
|
||||
archive,
|
||||
name,
|
||||
type_id: None,
|
||||
};
|
||||
if !resolved
|
||||
.iter()
|
||||
.any(|existing| same_resource_key(existing, &key))
|
||||
{
|
||||
resolved.push(key);
|
||||
}
|
||||
}
|
||||
match resolved.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(resolved.pop()),
|
||||
_ => Err(PrototypeError::Resource(ResourceError::Format(format!(
|
||||
"prototype {} has ambiguous explicit control resources: {}",
|
||||
String::from_utf8_lossy(&requested.0),
|
||||
resolved
|
||||
.iter()
|
||||
.map(|key| format!(
|
||||
"{}:{}",
|
||||
key.archive.as_str(),
|
||||
String::from_utf8_lossy(&key.name.0)
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)))),
|
||||
}
|
||||
}
|
||||
|
||||
fn same_resource_key(left: &ResourceKey, right: &ResourceKey) -> bool {
|
||||
left.archive
|
||||
.as_str()
|
||||
.eq_ignore_ascii_case(right.archive.as_str())
|
||||
&& eq_ignore_ascii_case(&left.name.0, &right.name.0)
|
||||
&& left.type_id == right.type_id
|
||||
}
|
||||
|
||||
fn describe_object_ref(item: &ObjectRefRecord) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
@@ -1195,12 +1384,16 @@ fn effective(
|
||||
requested: ResourceName,
|
||||
mesh: ResourceKey,
|
||||
source: PrototypeSource,
|
||||
source_type: Option<u32>,
|
||||
control_resource: Option<ResourceKey>,
|
||||
) -> EffectivePrototype {
|
||||
EffectivePrototype {
|
||||
key: PrototypeKey(requested),
|
||||
geometry: PrototypeGeometry::Mesh(mesh.clone()),
|
||||
source,
|
||||
source_type,
|
||||
dependencies: vec![mesh],
|
||||
control_resource,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,7 +1573,7 @@ mod tests {
|
||||
assert_eq!(record.parent_or_link, -7);
|
||||
assert_eq!(&record.description_raw[..description.len()], description);
|
||||
assert_eq!(record.tail0, 0x1122_3344);
|
||||
assert_eq!(record.tail1, 0x5566_7788);
|
||||
assert_eq!(record.immediate_child_count, 0x5566_7788);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1406,6 +1599,88 @@ mod tests {
|
||||
assert_eq!(unit.records[0].parent_or_link, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() {
|
||||
let mut bytes = build_unit_dat(&[
|
||||
(b"objects.rlb".as_slice(), b"root".as_slice()),
|
||||
(b"objects.rlb".as_slice(), b"child".as_slice()),
|
||||
(b"objects.rlb".as_slice(), b"grandchild".as_slice()),
|
||||
]);
|
||||
bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes());
|
||||
bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes());
|
||||
bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes());
|
||||
|
||||
let unit = decode_unit_dat(&bytes).expect("unit");
|
||||
let tree = unit_component_tree(&unit.records).expect("preorder tree");
|
||||
assert_eq!(tree.len(), 3);
|
||||
assert_eq!(tree[0].record_index, 0);
|
||||
assert_eq!(tree[0].parent_index, None);
|
||||
assert_eq!(tree[0].children, vec![1]);
|
||||
assert_eq!(tree[1].parent_index, Some(0));
|
||||
assert_eq!(tree[1].children, vec![2]);
|
||||
assert_eq!(tree[2].parent_index, Some(1));
|
||||
assert_eq!(tree[2].children, Vec::<usize>::new());
|
||||
assert_eq!(unit.records[1].parent_or_link, 7);
|
||||
assert_eq!(unit.records[2].parent_or_link, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_component_tree_handles_deep_preorder_chains_iteratively() {
|
||||
const DEPTH: usize = 32_768;
|
||||
let records: Vec<_> = (0..DEPTH)
|
||||
.map(|index| UnitComponentRecord {
|
||||
archive_raw: [0; 32],
|
||||
resource_raw: [0; 32],
|
||||
kind: 0,
|
||||
parent_or_link: 0,
|
||||
description_raw: [0; 32],
|
||||
tail0: 0,
|
||||
immediate_child_count: u32::from(index + 1 < DEPTH),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tree = unit_component_tree(&records).expect("deep preorder chain");
|
||||
|
||||
assert_eq!(tree.len(), DEPTH);
|
||||
assert_eq!(tree[0].parent_index, None);
|
||||
assert_eq!(tree[0].children, vec![1]);
|
||||
assert_eq!(tree[DEPTH / 2].parent_index, Some(DEPTH / 2 - 1));
|
||||
assert_eq!(tree[DEPTH - 2].children, vec![DEPTH - 1]);
|
||||
assert_eq!(tree[DEPTH - 1].parent_index, Some(DEPTH - 2));
|
||||
assert!(tree[DEPTH - 1].children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_component_tree_rejects_missing_child_records() {
|
||||
let bytes = build_unit_dat(&[
|
||||
(b"objects.rlb".as_slice(), b"root".as_slice()),
|
||||
(b"objects.rlb".as_slice(), b"child".as_slice()),
|
||||
]);
|
||||
let mut unit = decode_unit_dat(&bytes).expect("unit");
|
||||
unit.records[0].immediate_child_count = 2;
|
||||
|
||||
assert!(matches!(
|
||||
unit_component_tree(&unit.records),
|
||||
Err(PrototypeError::InvalidUnitHierarchy(message))
|
||||
if message.contains("child count reached record 2")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_component_tree_rejects_unconsumed_preorder_records() {
|
||||
let bytes = build_unit_dat(&[
|
||||
(b"objects.rlb".as_slice(), b"root".as_slice()),
|
||||
(b"objects.rlb".as_slice(), b"child".as_slice()),
|
||||
]);
|
||||
let unit = decode_unit_dat(&bytes).expect("unit");
|
||||
assert!(matches!(
|
||||
unit_component_tree(&unit.records),
|
||||
Err(PrototypeError::InvalidUnitHierarchy(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_synthetic_objects_registry_model() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
@@ -1436,6 +1711,7 @@ mod tests {
|
||||
.expect("prototype");
|
||||
|
||||
assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
|
||||
assert!(resolved.control_resource.is_none());
|
||||
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
|
||||
panic!("expected mesh");
|
||||
};
|
||||
@@ -1855,10 +2131,10 @@ mod tests {
|
||||
build_nres(&[
|
||||
(
|
||||
b"parent_proto".as_slice(),
|
||||
build_object_refs(&[(
|
||||
b"static.rlb".as_slice(),
|
||||
b"parent_proto.msh".as_slice(),
|
||||
)])
|
||||
build_object_refs(&[
|
||||
(b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()),
|
||||
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
|
||||
])
|
||||
.as_slice(),
|
||||
),
|
||||
(
|
||||
@@ -1866,6 +2142,7 @@ mod tests {
|
||||
build_object_refs(&[
|
||||
(b"objects.rlb".as_slice(), b"parent_proto".as_slice()),
|
||||
(b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()),
|
||||
(b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
|
||||
])
|
||||
.as_slice(),
|
||||
),
|
||||
@@ -1881,7 +2158,13 @@ mod tests {
|
||||
);
|
||||
vfs.insert(
|
||||
fortif_path,
|
||||
Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()),
|
||||
Arc::from(
|
||||
build_nres(&[
|
||||
(b"child_proto.bas".as_slice(), b"base"),
|
||||
(b"parent_proto.ctl".as_slice(), b"control"),
|
||||
])
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
);
|
||||
let vfs = Arc::new(vfs);
|
||||
let repo = CachedResourceRepository::new(vfs.clone());
|
||||
@@ -1895,6 +2178,108 @@ mod tests {
|
||||
};
|
||||
assert_eq!(mesh.archive.as_str(), "static.rlb");
|
||||
assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh"));
|
||||
let control = resolved.control_resource.expect("inherited explicit CTL");
|
||||
assert_eq!(control.archive.as_str(), "fortif.rlb");
|
||||
assert!(control.name.0.eq_ignore_ascii_case(b"parent_proto.ctl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_registry_retains_missing_explicit_control_metadata() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
|
||||
let static_path = resource_archive_path(b"static.rlb").expect("static path");
|
||||
let mesh = minimal_msh_payload();
|
||||
vfs.insert(
|
||||
objects_path,
|
||||
Arc::from(
|
||||
build_nres(&[(
|
||||
b"optional_control".as_slice(),
|
||||
build_object_refs(&[
|
||||
(b"static.rlb".as_slice(), b"optional_control.msh".as_slice()),
|
||||
(
|
||||
b"missing.rlb".as_slice(),
|
||||
b"optional_control.ctl".as_slice(),
|
||||
),
|
||||
])
|
||||
.as_slice(),
|
||||
)])
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
);
|
||||
vfs.insert(
|
||||
static_path,
|
||||
Arc::from(
|
||||
build_nres(&[(b"optional_control.msh".as_slice(), mesh.as_slice())])
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
);
|
||||
|
||||
let vfs = Arc::new(vfs);
|
||||
let repository = CachedResourceRepository::new(vfs.clone());
|
||||
let resolved = resolve_prototype_single(
|
||||
&repository,
|
||||
vfs.as_ref(),
|
||||
&resource_name(b"optional_control"),
|
||||
)
|
||||
.expect("resolve despite absent optional CTL")
|
||||
.expect("prototype");
|
||||
|
||||
let control = resolved
|
||||
.control_resource
|
||||
.expect("preserve explicit missing CTL reference");
|
||||
assert_eq!(control.archive.as_str(), "missing.rlb");
|
||||
assert_eq!(control.name, resource_name(b"optional_control.ctl"));
|
||||
assert_eq!(control.type_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_registry_rejects_distinct_explicit_controls() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
|
||||
let static_path = resource_archive_path(b"static.rlb").expect("static path");
|
||||
let controls_path = resource_archive_path(b"controls.rlb").expect("controls path");
|
||||
let mesh = minimal_msh_payload();
|
||||
vfs.insert(
|
||||
objects_path,
|
||||
Arc::from(
|
||||
build_nres(&[(
|
||||
b"prototype".as_slice(),
|
||||
build_object_refs(&[
|
||||
(b"static.rlb".as_slice(), b"prototype.msh".as_slice()),
|
||||
(b"controls.rlb".as_slice(), b"first.ctl".as_slice()),
|
||||
(b"controls.rlb".as_slice(), b"second.ctl".as_slice()),
|
||||
])
|
||||
.as_slice(),
|
||||
)])
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
);
|
||||
vfs.insert(
|
||||
static_path,
|
||||
Arc::from(
|
||||
build_nres(&[(b"prototype.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
|
||||
),
|
||||
);
|
||||
vfs.insert(
|
||||
controls_path,
|
||||
Arc::from(
|
||||
build_nres(&[
|
||||
(b"first.ctl".as_slice(), b"first control"),
|
||||
(b"second.ctl".as_slice(), b"second control"),
|
||||
])
|
||||
.into_boxed_slice(),
|
||||
),
|
||||
);
|
||||
let vfs = Arc::new(vfs);
|
||||
let repo = CachedResourceRepository::new(vfs.clone());
|
||||
let error = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"prototype"))
|
||||
.expect_err("distinct explicit controls are ambiguous");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
PrototypeError::Resource(ResourceError::Format(message))
|
||||
if message.contains("ambiguous explicit control resources")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -319,7 +319,7 @@ impl LegacyIron3dEulerTransform {
|
||||
|
||||
/// Fixed-function blend behaviour represented without a graphics API type.
|
||||
///
|
||||
/// This is a compatibility contract, not yet a decoded MAT0 mapping.
|
||||
/// This is the compatibility contract for the native MAT0 pipeline category.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum LegacyBlendMode {
|
||||
/// Do not blend the fragment with the existing colour.
|
||||
@@ -327,6 +327,14 @@ pub enum LegacyBlendMode {
|
||||
Opaque,
|
||||
/// Blend using source alpha.
|
||||
SourceAlpha,
|
||||
/// Add the source-alpha-scaled fragment to the existing colour.
|
||||
Additive,
|
||||
/// Replace the source factor with zero and use source colour as the
|
||||
/// destination factor.
|
||||
ZeroSourceColor,
|
||||
/// Multiply the source by destination colour and destination by source
|
||||
/// colour, matching the native two-colour blend mode.
|
||||
DestColorSourceColor,
|
||||
}
|
||||
|
||||
/// Depth-buffer behaviour represented without a graphics API type.
|
||||
@@ -369,10 +377,36 @@ pub struct LegacyPipelineState {
|
||||
pub alpha_test: bool,
|
||||
}
|
||||
|
||||
impl LegacyPipelineState {
|
||||
/// Applies the native MAT0 pipeline category while retaining the caller's
|
||||
/// depth and cull state.
|
||||
///
|
||||
/// Categories 0..4 are the complete table recovered from the native
|
||||
/// material path. The native table enables `GREATER_EQUAL` alpha testing
|
||||
/// for categories 1..4; the alpha reference remains per draw range.
|
||||
#[must_use]
|
||||
pub const fn with_material_category(self, category: u8) -> Option<Self> {
|
||||
let (blend, alpha_test) = match category {
|
||||
0 => (LegacyBlendMode::Opaque, false),
|
||||
1 => (LegacyBlendMode::SourceAlpha, true),
|
||||
2 => (LegacyBlendMode::Additive, true),
|
||||
3 => (LegacyBlendMode::ZeroSourceColor, true),
|
||||
4 => (LegacyBlendMode::DestColorSourceColor, true),
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
blend,
|
||||
alpha_test,
|
||||
..self
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical, backend-neutral key for a graphics-pipeline variant.
|
||||
///
|
||||
/// The value is explicitly packed rather than hashed, so captures and caches
|
||||
/// remain stable across processes and Rust toolchain updates.
|
||||
/// remain stable across processes and Rust toolchain updates. Three bits are
|
||||
/// reserved for the five native material blend modes.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct PipelineKey(u8);
|
||||
|
||||
@@ -389,6 +423,9 @@ impl From<LegacyPipelineState> for PipelineKey {
|
||||
let blend = match state.blend {
|
||||
LegacyBlendMode::Opaque => 0,
|
||||
LegacyBlendMode::SourceAlpha => 1,
|
||||
LegacyBlendMode::Additive => 2,
|
||||
LegacyBlendMode::ZeroSourceColor => 3,
|
||||
LegacyBlendMode::DestColorSourceColor => 4,
|
||||
};
|
||||
let depth = match state.depth {
|
||||
LegacyDepthMode::Disabled => 0,
|
||||
@@ -400,7 +437,7 @@ impl From<LegacyPipelineState> for PipelineKey {
|
||||
LegacyCullMode::BackFace => 1,
|
||||
LegacyCullMode::FrontFace => 2,
|
||||
};
|
||||
Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
|
||||
Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,4 +586,49 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_material_categories_preserve_depth_and_cull_state() {
|
||||
let base = LegacyPipelineState {
|
||||
depth: LegacyDepthMode::TestWrite,
|
||||
cull: LegacyCullMode::BackFace,
|
||||
..LegacyPipelineState::default()
|
||||
};
|
||||
let expected = [
|
||||
(LegacyBlendMode::Opaque, false),
|
||||
(LegacyBlendMode::SourceAlpha, true),
|
||||
(LegacyBlendMode::Additive, true),
|
||||
(LegacyBlendMode::ZeroSourceColor, true),
|
||||
(LegacyBlendMode::DestColorSourceColor, true),
|
||||
];
|
||||
for (category, (blend, alpha_test)) in expected.into_iter().enumerate() {
|
||||
let state = base
|
||||
.with_material_category(category as u8)
|
||||
.expect("native category is represented");
|
||||
assert_eq!(state.blend, blend);
|
||||
assert_eq!(state.alpha_test, alpha_test);
|
||||
assert_eq!(state.depth, base.depth);
|
||||
assert_eq!(state.cull, base.cull);
|
||||
}
|
||||
assert_eq!(base.with_material_category(5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_material_categories_have_distinct_pipeline_keys() {
|
||||
let base = LegacyPipelineState {
|
||||
depth: LegacyDepthMode::TestWrite,
|
||||
..LegacyPipelineState::default()
|
||||
};
|
||||
let keys: Vec<_> = (0..=4)
|
||||
.map(|category| {
|
||||
PipelineKey::from(
|
||||
base.with_material_category(category)
|
||||
.expect("native category is represented"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (index, key) in keys.iter().enumerate() {
|
||||
assert!(keys[index + 1..].iter().all(|other| other != key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1761,7 +1761,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_corpora_load_all_mission_foundations() {
|
||||
fn licensed_corpus_part1_loads_all_mission_foundations() {
|
||||
let part1 = load_all(&licensed_root("IS"));
|
||||
assert_eq!(part1.missions, 29);
|
||||
assert_eq!(part1.paths, 34);
|
||||
@@ -1781,7 +1781,11 @@ mod tests {
|
||||
assert_eq!(part1.material_slots, part1.material_resolved);
|
||||
assert_eq!(part1.texture_requests, part1.texture_resolved);
|
||||
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_corpus_part2_loads_all_mission_foundations() {
|
||||
let part2 = load_all(&licensed_root("IS2"));
|
||||
assert_eq!(part2.missions, 31);
|
||||
assert_eq!(part2.paths, 61);
|
||||
|
||||
@@ -43,16 +43,18 @@ pub struct CompactSurfaceMask(pub u16);
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct MaterialClassMask(pub u8);
|
||||
|
||||
/// The two positional material-table selectors packed into a terrain face tag.
|
||||
/// The base and overlay selectors packed into a terrain face tag.
|
||||
///
|
||||
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
|
||||
/// no-selection sentinel. The low byte selects from `Land2.wea`.
|
||||
/// Both selectors address the map-local `Land1.wea` table. The low byte is
|
||||
/// always the base row; the high byte is an optional overlay row and uses
|
||||
/// `0xff` as its no-overlay sentinel. `Land2.wea` is a separate detail bank
|
||||
/// selected by the corresponding detail path.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainMaterialLayers {
|
||||
/// Optional high-byte selector for `Land1.wea`.
|
||||
pub land1_selector: Option<u8>,
|
||||
/// Low-byte selector for `Land2.wea`.
|
||||
pub land2_selector: u8,
|
||||
/// Low-byte base selector for `Land1.wea`.
|
||||
pub base_selector: u8,
|
||||
/// Optional high-byte overlay selector for `Land1.wea`.
|
||||
pub overlay_selector: Option<u8>,
|
||||
}
|
||||
|
||||
/// One `World3D` material-manager lookup key.
|
||||
@@ -79,31 +81,56 @@ impl TerrainMaterialLayers {
|
||||
/// Constructs the decoded pair from its on-disk packed tag.
|
||||
#[must_use]
|
||||
pub const fn from_packed_tag(material_tag: u16) -> Self {
|
||||
let [land2_selector, land1] = material_tag.to_le_bytes();
|
||||
let [base_selector, overlay] = material_tag.to_le_bytes();
|
||||
Self {
|
||||
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
|
||||
land2_selector,
|
||||
base_selector,
|
||||
overlay_selector: if overlay == u8::MAX {
|
||||
None
|
||||
} else {
|
||||
Some(overlay)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the table-zero `Land1.wea` selection when it exists.
|
||||
/// Returns the base selection from `Land1.wea`.
|
||||
#[must_use]
|
||||
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
|
||||
self.land1_selector
|
||||
pub fn base_selection(self) -> TerrainMaterialSelection {
|
||||
TerrainMaterialSelection {
|
||||
table_index: 0,
|
||||
material_index: u16::from(self.base_selector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the optional overlay selection from `Land1.wea`.
|
||||
#[must_use]
|
||||
pub fn overlay_selection(self) -> Option<TerrainMaterialSelection> {
|
||||
self.overlay_selector
|
||||
.map(|material_index| TerrainMaterialSelection {
|
||||
table_index: 0,
|
||||
material_index: u16::from(material_index),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the table-one `Land2.wea` selection.
|
||||
/// Returns the detail selection corresponding to the base row in
|
||||
/// `Land2.wea`.
|
||||
#[must_use]
|
||||
pub fn land2_selection(self) -> TerrainMaterialSelection {
|
||||
pub fn detail_base_selection(self) -> TerrainMaterialSelection {
|
||||
TerrainMaterialSelection {
|
||||
table_index: 1,
|
||||
material_index: u16::from(self.land2_selector),
|
||||
material_index: u16::from(self.base_selector),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the optional detail selection corresponding to the overlay row
|
||||
/// in `Land2.wea`.
|
||||
#[must_use]
|
||||
pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> {
|
||||
self.overlay_selector
|
||||
.map(|material_index| TerrainMaterialSelection {
|
||||
table_index: 1,
|
||||
material_index: u16::from(material_index),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Terrain face with 28-byte source layout.
|
||||
@@ -231,7 +258,7 @@ pub struct LandMeshDocument {
|
||||
/// Packed normals from type 4.
|
||||
pub normals: Vec<[i8; 4]>,
|
||||
/// Packed UV from type 5.
|
||||
pub uv0: Vec<[i16; 2]>,
|
||||
pub uv0: Vec<[u16; 2]>,
|
||||
/// Type 11 accelerator words.
|
||||
pub accelerator: Vec<[u8; 4]>,
|
||||
/// Type 14 auxiliary words.
|
||||
@@ -631,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainF
|
||||
validate_slots(nres, slots)?;
|
||||
let positions = parse_positions(nres, positions)?;
|
||||
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
|
||||
let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
|
||||
let uv0 = parse_u16x2_stream(nres, TYPE_UV0, uv0)?;
|
||||
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
|
||||
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
|
||||
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
|
||||
@@ -683,6 +710,11 @@ pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFo
|
||||
let payload = nres.payload(entry.id())?;
|
||||
let areal_count =
|
||||
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
|
||||
checked_count_bytes(
|
||||
u64::from(meta.attr1),
|
||||
u64::try_from(AREAL_PREFIX_SIZE).map_err(|_| TerrainFormatError::IntegerOverflow)?,
|
||||
payload.len() as u64,
|
||||
)?;
|
||||
let mut cursor = Cursor::new(payload);
|
||||
let mut areals = Vec::with_capacity(areal_count);
|
||||
for area_index in 0..areal_count {
|
||||
@@ -1072,11 +1104,11 @@ fn parse_i8x4_stream(
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn parse_i16x2_stream(
|
||||
fn parse_u16x2_stream(
|
||||
nres: &NresDocument,
|
||||
type_id: u32,
|
||||
payload: &[u8],
|
||||
) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
|
||||
) -> Result<Vec<[u16; 2]>, TerrainFormatError> {
|
||||
if !payload.len().is_multiple_of(4) {
|
||||
return Err(TerrainFormatError::InvalidSize {
|
||||
type_id,
|
||||
@@ -1088,7 +1120,7 @@ fn parse_i16x2_stream(
|
||||
validate_stream(nres, type_id, 4, count)?;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for chunk in payload.chunks_exact(4) {
|
||||
out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
|
||||
out.push([read_u16(chunk, 0)?, read_u16(chunk, 2)?]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1311,6 +1343,7 @@ fn parse_areal_grid(
|
||||
if cell_count == 0 {
|
||||
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
|
||||
}
|
||||
checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?;
|
||||
let cell_count_usize =
|
||||
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
|
||||
let mut cells = Vec::with_capacity(cell_count_usize);
|
||||
@@ -1364,13 +1397,6 @@ fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
|
||||
Ok(u16::from_le_bytes([raw[0], raw[1]]))
|
||||
}
|
||||
|
||||
fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
|
||||
let raw = bytes
|
||||
.get(offset..offset + 2)
|
||||
.ok_or(TerrainFormatError::IntegerOverflow)?;
|
||||
Ok(i16::from_le_bytes([raw[0], raw[1]]))
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
|
||||
let raw = bytes
|
||||
.get(offset..offset + 4)
|
||||
@@ -1574,7 +1600,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_material_tag_decodes_two_sidecar_selectors() {
|
||||
fn terrain_material_tag_decodes_base_overlay_and_detail_selectors() {
|
||||
let mut raw_face = face([0, 1, 2], [None, None, None]);
|
||||
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
|
||||
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
|
||||
@@ -1583,24 +1609,40 @@ mod tests {
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers(),
|
||||
TerrainMaterialLayers {
|
||||
land1_selector: Some(1),
|
||||
land2_selector: 2,
|
||||
base_selector: 2,
|
||||
overlay_selector: Some(1),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers().land1_selection(),
|
||||
Some(TerrainMaterialSelection {
|
||||
document.faces[0].material_layers().base_selection(),
|
||||
TerrainMaterialSelection {
|
||||
table_index: 0,
|
||||
material_index: 1,
|
||||
})
|
||||
material_index: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0]
|
||||
.material_layers()
|
||||
.land2_selection()
|
||||
.overlay_selection()
|
||||
.expect("overlay")
|
||||
.phase_key(),
|
||||
0x0000_0001
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0]
|
||||
.material_layers()
|
||||
.detail_base_selection()
|
||||
.phase_key(),
|
||||
0x0001_0002
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0]
|
||||
.material_layers()
|
||||
.detail_overlay_selection()
|
||||
.expect("detail overlay")
|
||||
.phase_key(),
|
||||
0x0001_0001
|
||||
);
|
||||
|
||||
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
|
||||
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
|
||||
@@ -1608,18 +1650,28 @@ mod tests {
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers(),
|
||||
TerrainMaterialLayers {
|
||||
land1_selector: None,
|
||||
land2_selector: 3,
|
||||
base_selector: 3,
|
||||
overlay_selector: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers().land2_selection(),
|
||||
document.faces[0].material_layers().base_selection(),
|
||||
TerrainMaterialSelection {
|
||||
table_index: 1,
|
||||
table_index: 0,
|
||||
material_index: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers().overlay_selection(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0]
|
||||
.material_layers()
|
||||
.detail_base_selection()
|
||||
.phase_key(),
|
||||
0x0001_0003
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1637,6 +1689,21 @@ mod tests {
|
||||
assert_eq!(document.grid.compact_cells, [0x0040_0000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn land_map_bounds_area_and_grid_reservations_by_payload() {
|
||||
let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]);
|
||||
let nres = decode_nres(&empty).expect("nres");
|
||||
assert!(decode_land_map(&nres).is_err());
|
||||
|
||||
let mut payload = Vec::new();
|
||||
push_areal_prefix(&mut payload, 0, 0);
|
||||
push_u32(&mut payload, 65_535);
|
||||
push_u32(&mut payload, 65_535);
|
||||
let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]);
|
||||
let nres = decode_nres(&bytes).expect("nres");
|
||||
assert!(decode_land_map(&nres).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
|
||||
let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Validated terrain runtime queries using XY ground coordinates and Z height.
|
||||
|
||||
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
||||
use fparkan_terrain_format::{LandMapDocument, LandMeshDocument};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Terrain material-selector contract preserved from the decoded map mesh.
|
||||
///
|
||||
/// Applications access this semantic terrain API through this terrain crate
|
||||
/// rather than taking a direct dependency on the binary-format parser.
|
||||
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
|
||||
pub use fparkan_terrain_format::{
|
||||
FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection,
|
||||
};
|
||||
|
||||
/// Terrain world.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -141,6 +143,21 @@ pub trait SurfaceQuery {
|
||||
direction: [f32; 3],
|
||||
mask: FullSurfaceMask,
|
||||
) -> Result<Option<SurfaceHit>, TerrainError>;
|
||||
|
||||
/// Raycast while requiring every bit in `include` and rejecting every
|
||||
/// face carrying a bit in `exclude`.
|
||||
///
|
||||
/// The native landscape query has both predicates. The legacy
|
||||
/// [`Self::raycast`] shorthand keeps its historical include-only form;
|
||||
/// callers that model a native receiver or visibility test should use
|
||||
/// this method so exclusion flags cannot be mistaken for inclusion bits.
|
||||
fn raycast_excluding(
|
||||
&self,
|
||||
origin: [f32; 3],
|
||||
direction: [f32; 3],
|
||||
include: FullSurfaceMask,
|
||||
exclude: FullSurfaceMask,
|
||||
) -> Result<Option<SurfaceHit>, TerrainError>;
|
||||
}
|
||||
|
||||
/// Navigation query.
|
||||
@@ -404,6 +421,28 @@ impl SurfaceQuery for TerrainWorld {
|
||||
origin: [f32; 3],
|
||||
direction: [f32; 3],
|
||||
mask: FullSurfaceMask,
|
||||
) -> Result<Option<SurfaceHit>, TerrainError> {
|
||||
self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0))
|
||||
}
|
||||
|
||||
fn raycast_excluding(
|
||||
&self,
|
||||
origin: [f32; 3],
|
||||
direction: [f32; 3],
|
||||
include: FullSurfaceMask,
|
||||
exclude: FullSurfaceMask,
|
||||
) -> Result<Option<SurfaceHit>, TerrainError> {
|
||||
self.raycast_filtered(origin, direction, include, exclude)
|
||||
}
|
||||
}
|
||||
|
||||
impl TerrainWorld {
|
||||
fn raycast_filtered(
|
||||
&self,
|
||||
origin: [f32; 3],
|
||||
direction: [f32; 3],
|
||||
include: FullSurfaceMask,
|
||||
exclude: FullSurfaceMask,
|
||||
) -> Result<Option<SurfaceHit>, TerrainError> {
|
||||
if self.surfaces.is_empty() {
|
||||
return Err(TerrainError::Unsupported);
|
||||
@@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
|
||||
let mut best: Option<SurfaceHit> = None;
|
||||
for index in candidates {
|
||||
let triangle = &self.surfaces[index];
|
||||
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
|
||||
if include.0 != 0 && triangle.mask.0 & include.0 != include.0 {
|
||||
continue;
|
||||
}
|
||||
if triangle.mask.0 & exclude.0 != 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(distance) = triangle.raycast(origin, direction) else {
|
||||
@@ -1032,6 +1074,31 @@ mod tests {
|
||||
.expect("raycast"),
|
||||
None
|
||||
);
|
||||
|
||||
// Native include masks require every requested bit, while exclusion
|
||||
// masks reject any matching face bit.
|
||||
assert_eq!(
|
||||
world
|
||||
.raycast_excluding(
|
||||
[0.25, 0.25, 2.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
FullSurfaceMask(0x0000_0003),
|
||||
FullSurfaceMask(0),
|
||||
)
|
||||
.expect("raycast include all bits"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.raycast_excluding(
|
||||
[0.25, 0.25, 2.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
FullSurfaceMask(0),
|
||||
FullSurfaceMask(0x0000_0001),
|
||||
)
|
||||
.expect("raycast exclusion"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ messages, teleports, задачи, research и campaign transitions. Точки
|
||||
и `briefing.cfg`.
|
||||
|
||||
`.scr` — binary package с version checks, symbol/event sections и offsets;
|
||||
полная opcode grammar не доказана. Его внешний framing теперь читает
|
||||
полная opcode grammar не доказана. Его внешний framing читает
|
||||
`fparkan-script`: первый little-endian `u32` является числом required opcode
|
||||
handlers, второй — числом event records. Каждый event хранит `name_len`,
|
||||
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
|
||||
@@ -108,24 +108,21 @@ unsupported result, а не «примерный» game command.
|
||||
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
|
||||
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
|
||||
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
|
||||
находится в отдельном позднем update path. Воспроизводимый read-only extractor:
|
||||
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
|
||||
находится в отдельном позднем update path.
|
||||
|
||||
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr`
|
||||
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый
|
||||
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266`
|
||||
читает первые два reference words активной instruction, разрешает каждый
|
||||
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с
|
||||
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
|
||||
Во всех 58 GOG `.scr` имеются 6 087 instruction records, включая 3 992
|
||||
sentinel. Самый частый non-sentinel selector — `Handler(30)`, 246 records.
|
||||
Его VA `0x1000c266` читает первые два reference words активной instruction,
|
||||
разрешает каждый через varset (`0x10002d30` и `0x10013570`) и вызывает внешний
|
||||
callback с тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
|
||||
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
|
||||
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
|
||||
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока
|
||||
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
|
||||
text-to-varset resolver расположен за wrapper `0x10011ea0` в
|
||||
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`,
|
||||
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
|
||||
`0x100174a0`.
|
||||
|
||||
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
|
||||
`0x100174a0` добавляет каждый
|
||||
recognized source declaration в encounter order как 48-byte record; GOG shared
|
||||
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
|
||||
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
|
||||
@@ -134,10 +131,9 @@ capture. Полный GOG scan всех 246 Handler(30) instructions показ
|
||||
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
|
||||
`VarSet::resolve_handler30` уже materializes точный opaque callback command
|
||||
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
|
||||
out-of-range и incomplete instructions вместо silent coercion. Extractors:
|
||||
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
|
||||
out-of-range и incomplete instructions вместо silent coercion.
|
||||
|
||||
Следующий static pass закрывает equality/update policy. Identity ровно равна
|
||||
Identity ровно равна
|
||||
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
|
||||
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
|
||||
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
|
||||
@@ -147,7 +143,7 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+
|
||||
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
|
||||
не назначает игровых имён и не делает event lookup за original VM.
|
||||
|
||||
На границе mission runtime выбранный TMA clan `first_resource` теперь
|
||||
На границе mission runtime выбранный TMA clan `first_resource`
|
||||
материализуется как отдельный `MissionScriptBundle`: loader нормализует
|
||||
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
|
||||
package вместе с clan provenance. Это именно wiring входных данных, не VM
|
||||
@@ -206,9 +202,9 @@ contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
|
||||
the Rust loader reports `script_init_states=2` and `script_varset_states=2`.
|
||||
|
||||
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
|
||||
`ai.dll + 0x55398` for `n <= 63`. The read-only
|
||||
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
|
||||
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields
|
||||
`ai.dll + 0x55398` for `n <= 63`. A read-only capture of the running GOG
|
||||
AutoDemo process observed `(500, 752, 0)` for entry 0 and `(728, 449, 1)` for
|
||||
entry 1 at fields
|
||||
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
|
||||
profile.
|
||||
|
||||
@@ -216,12 +212,7 @@ The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
|
||||
converted first two words and the third raw word, produces three typed writes,
|
||||
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
|
||||
binds it to the proven creation/anchor path above; it does not guess the
|
||||
remaining script event semantics. The associated Ghidra scripts are
|
||||
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
|
||||
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
|
||||
The creation and conversion boundaries are reproducible with
|
||||
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
|
||||
`ExportAiFtol.java`.
|
||||
remaining script event semantics.
|
||||
|
||||
### Runtime Handler(30) operand binding
|
||||
|
||||
@@ -256,12 +247,7 @@ proves the lookup and one-shot/repeat split, not the UI/message subject or the
|
||||
semantics of either resource ID; Rust therefore retains command `1` as
|
||||
`Unhandled`.
|
||||
|
||||
Reproduce the callback and the command-one consumers with
|
||||
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
|
||||
`ExportIron3dAiCallbackCommand1.java`, and
|
||||
`ExportIron3dAiCallbackCommand1Dispatch.java`.
|
||||
|
||||
Runtime now applies only this recovered branch as
|
||||
Runtime applies only this branch as
|
||||
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission
|
||||
to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target
|
||||
state is a no-op just as the Iron3D guards require. The effect is deliberately
|
||||
@@ -284,9 +270,7 @@ opaque callback slots; state `3` does the same except word `3` is preserved.
|
||||
Both then write `+0x18`. Every other state simply writes the state word.
|
||||
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
|
||||
live record index, resolved state, and explicit reset kind. It does not invent
|
||||
the table owner, the pre-reset helper, or callback semantics. Reproduce the
|
||||
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
|
||||
`ExportAiVmHandler8Transitions.java`.
|
||||
the table owner, the pre-reset helper, or callback semantics.
|
||||
|
||||
### Handler(15): typed target-call boundary
|
||||
|
||||
@@ -314,8 +298,7 @@ with that record and the third word. Its zero/non-zero result becomes
|
||||
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
|
||||
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
|
||||
it never executes the opaque target call. Missing, out-of-range, wrong-type,
|
||||
and unobserved-mode inputs are explicit errors. Reproduce the static evidence
|
||||
with `tools/ghidra/ExportAiVmHandler15.java`.
|
||||
and unobserved-mode inputs are explicit errors.
|
||||
|
||||
## Готовность
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ FParkan воспроизводит его работу на Rust и Vulkan. На
|
||||
7. [Работа над движком](tomes/07-implementation.md): код, тесты и проверка результата.
|
||||
8. [Устройство оригинальной программы](tomes/08-evidence.md): DLL, адреса и конфигурация.
|
||||
|
||||
В справочнике можно быстро сверить [NRes](reference/nres.md),
|
||||
[RsLi](reference/rsli.md), [TMA](reference/tma.md), [MSH](reference/msh.md),
|
||||
[материалы](reference/materials.md), [Texm](reference/texm.md),
|
||||
[кадр рендера](reference/render-frame.md), [атмосферу и небо](reference/atmosphere.md)
|
||||
и [эффекты окружения](reference/environment-effects.md). Таблица DLL находится
|
||||
в [описании оригинальных модулей](reference/original-binaries.md).
|
||||
|
||||
В [глоссарии](appendices/glossary.md) собраны термины, а в
|
||||
[открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное
|
||||
поведение. Описание алгоритма оригинала и возможности текущего приложения
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# Атмосфера и небо
|
||||
|
||||
Атмосфера Parkan задаётся двумя связанными ресурсами: `sky.ske` хранит
|
||||
расписание, цвета и параметры, а `sky.wea` сопоставляет позиционные строки с
|
||||
материалами. Ресурсы сначала разбираются в `TypedAtmosphere` и `SkyMaterials`,
|
||||
затем на каждом кадре из расписания получается `AtmosphereFrame`. Модуль
|
||||
`fparkan-fx::sky` превращает этот кадр в данные для рендерера: постоянную
|
||||
геометрию купола, динамические цвета вершин, небесные слои, положение
|
||||
светил, туман и нижнюю границу освещения.
|
||||
|
||||
## Игровые сутки и смена дня и ночи
|
||||
|
||||
Вызов `TypedAtmosphere::sample(real_seconds)` принимает секунды от начала
|
||||
повторяющегося расписания. Дорожки идут в порядке файла и занимают свои
|
||||
реальные длительности; после последней дорожки время возвращается к началу.
|
||||
Внутри выбранной дорожки игровой день всегда проходит от 00:00 до 24:00:
|
||||
|
||||
```text
|
||||
track_seconds = real_seconds_in_track
|
||||
day_seconds = track_seconds / track_duration * 86400
|
||||
```
|
||||
|
||||
Время ключа вычисляется по часам и минутам даты. Нативный конструктор
|
||||
игнорирует сохранённое поле секунд, поэтому для планировщика используется:
|
||||
|
||||
```text
|
||||
duration = (end_hour * 60 + end_minute) * 60
|
||||
key_time = floor(duration * (key_hour * 60 + key_minute) * 60 / 86400)
|
||||
```
|
||||
|
||||
Сэмплер ищет предыдущий и следующий ключ во всём расписании. Переход через
|
||||
конец дорожки или конец цикла интерполируется так же, как переход внутри
|
||||
дорожки. `SkeDate::seconds_of_day()` сохраняет все три компонента даты для
|
||||
инструментов; оно не меняет формулу реального времени расписания.
|
||||
|
||||
День и ночь в самом формате не являются отдельным флагом. Их вид получается
|
||||
из интерполированных цветовых параметров купола и состояния объектов солнца и
|
||||
луны. Ключи с kind `0` и `1` запускают и останавливают небесное светило. Для
|
||||
активного интервала модуль восстанавливает рождение объекта: ищет ближайший
|
||||
предыдущий ключ `SunStart`, следующий соответствующий `SunStop`, вычисляет
|
||||
реальное время начала и длительность, включая переход через цикл, и сохраняет
|
||||
эти значения в `SunBirth`.
|
||||
|
||||
Имя в первой фиксированной строке определяет объект. Для строки `sun`
|
||||
нативный код использует углы `[90, 30]` градусов и строку материала 3; для
|
||||
`moon` — `[0, 50]` и строку 4. Начальный вектор орбиты строится по формуле
|
||||
`[cos(theta), 0, -sin(theta)]`, где
|
||||
|
||||
```text
|
||||
theta = (clamp((now - birth_start) / lifetime, 0, 1) * 1.2 - 0.1) * pi
|
||||
```
|
||||
|
||||
Затем к нему применяется матрица рождения. У солнца и луны независимые
|
||||
интервалы и независимые направления: при отсутствии активной луны нельзя
|
||||
подменять её направление противоположностью солнца. `SunFrame::color` — это
|
||||
единственный primary directional RGB. Второй источник получает направление
|
||||
`-SunFrame::direction` и RGB из `SunSample::packed[1]`; оба источника для
|
||||
активного объекта находятся в `SkyFrame::directional_lights` (сначала солнце,
|
||||
затем луна). Экранная геометрия использует `-SunFrame::direction`, а
|
||||
`world_center` размещает её по нативной формуле относительно camera
|
||||
translation и половины far plane. Материал, строка и состояние объекта
|
||||
берутся из его `SunBirth` и таблицы `sky.wea`.
|
||||
|
||||
## Купол
|
||||
|
||||
Оригинальный `CSky` строит конечный купол, а не кубическую skybox. Значения
|
||||
конструктора, подтверждённые в Terrain, такие: высота `10000`,
|
||||
`theta_max = pi / 4`, вертикальный масштаб `1`, `16` азимутальных секторов и
|
||||
`5` колец. Радиус вычисляется как
|
||||
|
||||
```text
|
||||
radius = height * 0.5 / sin(theta_max * 0.5)^2
|
||||
```
|
||||
|
||||
Вершина с индексом 0 находится в зените `[0, 0, height * z_scale]`.
|
||||
Остальные вершины идут в порядке `azimuth`, затем `ring`; для них
|
||||
|
||||
```text
|
||||
phi = azimuth / azimuth_count * 2*pi
|
||||
theta = (ring + 1) / rings * theta_max
|
||||
x = radius * sin(theta) * sin(phi)
|
||||
y = radius * sin(theta) * cos(phi)
|
||||
z = (radius * cos(theta) + height - radius) * z_scale
|
||||
```
|
||||
|
||||
На сектор приходится один треугольник веера и по два треугольника на каждую
|
||||
пару соседних колец. Текстурные координаты трёх исходных стадий используют
|
||||
масштабы `[1, 15, 3]` для `position.x / radius` и `position.y / radius`.
|
||||
Цвет зенита и первого пояса берётся из palette 12. Следующие пояса используют
|
||||
группы palette 8..11, 4..7 и 0..3 с интерполяцией по азимуту.
|
||||
|
||||
Позиции, signed normal bytes, UV и индексы создаются один раз через
|
||||
`SkyGeometryConfig::mesh`/`SkySystem::new`. Последующие кадры меняют только
|
||||
цвета вершин методом `SkyMesh::update_colors`; индексы и topology не
|
||||
пересоздаются. Ошибка конфигурации возвращается как `SkyGeometryError`, а
|
||||
ошибка входного кадра — как `SkyUpdateError`; они не превращаются в пустой
|
||||
буфер. Рендерер может загрузить `SkySystem::mesh()` в статический
|
||||
vertex/index buffer и обновлять только небольшой цветовой диапазон.
|
||||
|
||||
Нормали купола повторяют native `Terrain47BFF` contract: каждый компонент
|
||||
хранится как signed `i8`, округляется режимом nearest-even и умножается на
|
||||
`127.0`. `SkyVertex::normal_vector()` декодирует эти bytes делением на `127`;
|
||||
зенит имеет `[0, 0, 127]`. Вершинный backend должен передавать signed byte
|
||||
representation без трактовки его как unsigned `0..255`.
|
||||
|
||||
## Облака, звёзды и спрайты
|
||||
|
||||
`sky.wea` — позиционная таблица. В стандартном AutoDemo строки имеют роли:
|
||||
|
||||
| Строка | Роль | Имя в AutoDemo |
|
||||
|---:|---|---|
|
||||
| 0 | фон/туманность | `ENV_NEBULA_0` |
|
||||
| 1 | звёзды | `ENV_STARS` |
|
||||
| 2 | облака | `ENV_CLOUDS` |
|
||||
| 3 | солнце | `ENV_SUN_3` |
|
||||
| 4 | луна | `ENV_MOON` |
|
||||
| 5 | первый flare | `ENV_FLARE_00` |
|
||||
| 6 | второй flare | `ENV_FLARE_01` |
|
||||
| 7 | снежинка | `SNOWFLAKE` |
|
||||
| 8 | капля дождя | `RAIN_DROP` |
|
||||
|
||||
Это наблюдаемые строки, а не глобальные имена, на которые можно полагаться в
|
||||
миссии. `SkyMaterials::parse` сохраняет числовой id и имя каждой строки;
|
||||
миссия может заменить любой материал. `SkyLayerKind` и `SkyLayerFrame` дают
|
||||
роль, позиционную строку, активность, intensity, UV stage и фазу времени.
|
||||
|
||||
Нативный порядок купола находится в `SkyFrame::passes`: экранный gradient,
|
||||
nebula row 0 со stars row 1 как второй texture (material mode 4), dome
|
||||
gradient и clouds row 2. Для nebula/stars/clouds используются UV scales
|
||||
`1/15/3`; clouds получают translation Z `-5000` относительно камеры и
|
||||
directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три
|
||||
независимых цветных слоя.
|
||||
|
||||
Цвет первого screen-gradient и последнего пояса вычисляет
|
||||
`SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала
|
||||
округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор
|
||||
`(floor(degrees / 90) - 2) mod 4`, а затем смешивает соседние цвета
|
||||
`SkySample::colors[0..4]`. Остаток сектора умножается на точный коэффициент
|
||||
из PE `65F64` (`0x3C360B61`, примерно `0.01113567`), alpha результата равна
|
||||
`255`. `glare_rgb_delta` — входной RGB delta от light manager: для каждого
|
||||
канала это `max(current_primary_light_rgb - base_sun_rgb, 0)`. Каждый компонент
|
||||
затем преобразуется так: `d <= 1 -> d*0.8`, `1 < d <= 3 -> d*0.1 + 0.7`,
|
||||
`d > 3 -> 1`.
|
||||
`SkyGradientFrame::horizon_floor` задаёт RGB minimum для dome palette, а
|
||||
`clamp_dome_color` применяет его, сохраняя alpha исходного цвета.
|
||||
|
||||
Солнце, луна и две строки flare являются направленными экранными слоями.
|
||||
Нативный quad использует UV
|
||||
`[[.005,.005],[.005,.995],[.995,.995],[.995,.005]]`. Полуразмеры sun/moon в
|
||||
пикселях вычисляет `sprite_half_size_pixels`: `viewport_width / horizontal_fov
|
||||
* .325 * .5 * values[0/1]`, где `values` — первые два tail float SKE.
|
||||
Цвет sprite и его alpha берутся из `SunSample::packed[0]`, отдельно от
|
||||
directional `SunFrame::color`.
|
||||
|
||||
`SkyFrame::sun_optics` принимает projected sun, viewport, camera forward и
|
||||
результат world-ray visibility. Он применяет native 15-degree glare cone и
|
||||
возвращает фиксированные 12 `FlareQuad`; flare slots активны только при
|
||||
видимом солнце, `length(primary RGB) > 1.1` и cone amount `<= .1`. При cone
|
||||
`> .1` остаётся только glare boost. Renderer отвечает за сам projection и
|
||||
occlusion query.
|
||||
|
||||
Дождь, снег и молния используют те же состояния расписания, но их camera-local
|
||||
геометрия и звуки обновляются `EnvironmentSystem`. Для активного интервала
|
||||
ресурсы берутся только из ключа, который его запустил. Интенсивность дождя,
|
||||
снега и молнии — четвёртый trailing float; цвет активной погоды использует
|
||||
RGB palette 12 с минимумом `80/255` на канал и alpha `150/255`.
|
||||
|
||||
## Туман и свет
|
||||
|
||||
Нативное обновление `CSky` записывает две дальности и один packed RGB.
|
||||
`FogFrame` повторяет этот контракт:
|
||||
|
||||
```text
|
||||
fog.start = sky.values[0] * 700
|
||||
fog.end = sky.values[1] * 700
|
||||
fog.color = RGB(sky.packed[1]) / 255
|
||||
```
|
||||
|
||||
Поля не скрывают порядок исходных параметров: значения сохраняются в
|
||||
`SkySample`, а преобразование выполняется только в `fog_frame`. Vulkan path
|
||||
передаёт цвет и дальности в frame uniforms; vertex shader вычисляет линейный
|
||||
коэффициент по расстоянию до камеры, а fragment shader смешивает RGB материала
|
||||
с fog color в ветвях combiner, которые используют туман. Конкретный draw state
|
||||
может обходить это смешивание.
|
||||
|
||||
Цвет primary света солнца — RGB palette 12, нормированный делением на 255 и
|
||||
умноженный на trailing parameter 2. `SunFrame::direction` — нормированный
|
||||
вектор после матрицы рождения и направление primary light; sprite/world
|
||||
geometry использует его противоположность. Второй directional RGB берётся из
|
||||
packed-параметра 1 без дополнительного умножения intensity. Когда объект
|
||||
неактивен, sampled поля сохраняются, но оба light slot и sprite inactive.
|
||||
|
||||
В native `CShade` sampled RGB из состояния атмосферы также устанавливает
|
||||
глобальную нижнюю границу освещения. Для каждого RGB-канала она объединяется
|
||||
операцией `max` с накопленным цветом материала. Поэтому `SkyFrame` отдаёт
|
||||
`lighting_floor` из того же sampled packed RGB, а владелец renderer передаёт
|
||||
его в глобальный light state. Фиксированный ambient-цвет в этом месте меняет
|
||||
ночной уровень и не соответствует цепочке Terrain → CShade → Ngi32.
|
||||
|
||||
## Бинарный формат `sky.ske`
|
||||
|
||||
Файл little-endian, версия 5. В начале находятся `i32 marker = -1`,
|
||||
`u32 version = 5` и число дорожек. Дорожка содержит `u32 version = 1`, число
|
||||
ключей, две даты по 32 байта и ключи. Дата состоит из восьми `u32`; для
|
||||
планировщика значимы слова 3, 4 и 5 (часы, минуты, секунды), а остальные слова
|
||||
сохраняются без интерпретации.
|
||||
|
||||
Ключ имеет `u32 version = 3`, дату, raw kind, opaque word, четыре packed ARGB
|
||||
цвета, два `f32`, пятнадцать packed ARGB цветов, шесть length-prefixed строк,
|
||||
четыре trailing `f32` и список length-prefixed ссылок. Строка хранится как
|
||||
`u32 byte_length` и ровно столько байт без завершающего NUL. Неизвестный kind
|
||||
сохраняется как `AtmosphereKind::Unknown`, как и opaque слова/строки.
|
||||
|
||||
В конце находятся дата и два opaque `u32`. Декодер ограничивает количества и
|
||||
размеры строк, отклоняет NaN/Infinity во float-полях и требует точного конца
|
||||
файла. Трейлер выбирает исходную дорожку и позицию час/минута; метод
|
||||
`initial_offset_seconds()` складывает длительности предыдущих дорожек и
|
||||
применяет ту же формулу floor.
|
||||
|
||||
Из интерполированного ключа `SkySample` формируется в исходном порядке:
|
||||
|
||||
```text
|
||||
colors = [header[1], header[2], header[0], header[3],
|
||||
palette[0], palette[3], palette[1], palette[2],
|
||||
palette[4], palette[7], palette[5], palette[6], palette[8]]
|
||||
values = header_values
|
||||
packed = [palette[11], palette[13]]
|
||||
```
|
||||
|
||||
Солнце и луна используют palette 12 и trailing parameter 2 для RGB,
|
||||
parameters 0 и 1 сохраняются как raw values, а packed-параметры светила —
|
||||
palette 10 и 14. Kind `0/1` управляет солнцем и луной по имени, `3/4` —
|
||||
дождём, `5/6` — снегом, `8/9` — молнией; kind `2` и `7` меняют
|
||||
интерполируемое состояние. Поля, для которых в native renderer ещё нет
|
||||
проверенного назначения, остаются raw в Rust API и не получают придуманных
|
||||
значений по умолчанию.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Эффекты окружения
|
||||
|
||||
Погода в Parkan состоит из двух связанных частей. Файл `sky.ske` задаёт
|
||||
расписание: когда начинается дождь или снег, как меняются интенсивность и цвет,
|
||||
какие имена ресурсов принадлежат активному интервалу. Таблица `sky.wea` задаёт
|
||||
материалы, которыми эти осадки рисуются. `fparkan-fx::environment` соединяет
|
||||
оба входа с текущей камерой и возвращает один `EnvironmentFrame`.
|
||||
|
||||
```text
|
||||
sky.ske
|
||||
-> AtmosphereFrame: время, цвет, интенсивность, ссылки на ресурсы
|
||||
sky.wea
|
||||
-> SkyMaterials: имена материалов строк 7 (снег) и 8 (дождь)
|
||||
камера + EnvironmentSystem
|
||||
-> EnvironmentFrame: мировые частицы, экранные квадраты, гром и дождевой loop
|
||||
```
|
||||
|
||||
Модуль не владеет графическим устройством или звуковой картой. Renderer
|
||||
разрешает имя материала и отправляет геометрию в прозрачный проход, а audio
|
||||
backend разрешает архив и имя звука. Это позволяет одной и той же модели
|
||||
погоды работать в preview и в игровом цикле.
|
||||
|
||||
## Входы и границы
|
||||
|
||||
Для расписания используется такой вызов:
|
||||
|
||||
```rust
|
||||
let frame = environment.update_atmosphere_with_materials(
|
||||
dt_seconds,
|
||||
&atmosphere_frame,
|
||||
&sky_materials,
|
||||
camera,
|
||||
);
|
||||
```
|
||||
|
||||
`AtmosphereFrame` передаёт ссылки из стартового ключа активного интервала.
|
||||
Для дождя первая ссылка используется как звук фонового loop. Строка вида
|
||||
`archive/name` разделяется на архив и имя. У простой строки `name` архив
|
||||
остаётся пустым: звуковой владелец подставляет библиотеку, выбранную миссией.
|
||||
Имя не заменяется глобальным именем из AutoDemo.
|
||||
|
||||
`SkyMaterials` хранит разобранную таблицу, поэтому строки 7 и 8 являются
|
||||
положением в формате исходной игры, а не зашитыми названиями. В AutoDemo там
|
||||
находятся `SNOWFLAKE` и `RAIN_DROP`; миссия может передать другие имена. Если
|
||||
активной погоде не назначен материал, частицы для неё не создаются.
|
||||
|
||||
На входной границе конечные значения приводятся к безопасному диапазону:
|
||||
`dt_seconds` и интенсивность неотрицательны, интенсивность и каждый компонент
|
||||
цвета ограничены единицей, а нечисловые значения заменяются нулём. Камера
|
||||
получает конечную ортонормированную основу, FOV, aspect ratio и размер
|
||||
viewport от владельца кадра.
|
||||
|
||||
## Объём и движение осадков
|
||||
|
||||
Исходный эмиттер хранит прямоугольный объём в координатах камеры. Его
|
||||
эталонные параметры таковы:
|
||||
|
||||
```text
|
||||
near = 2
|
||||
far = 50
|
||||
half_angle_x = 0.65
|
||||
half_angle_y = 0.4875
|
||||
```
|
||||
|
||||
Для эталонной камеры:
|
||||
|
||||
```text
|
||||
half_x = tan(half_angle_x) * far
|
||||
half_y = tan(half_angle_y) * far
|
||||
origin = [near, -half_x, -half_y]
|
||||
extent = [far - near, 2 * half_x, 2 * half_y]
|
||||
```
|
||||
|
||||
Текущий FOV и aspect ratio пересчитывают две боковые грани по той же глубине:
|
||||
|
||||
```text
|
||||
half_y = tan(vertical_fov / 2) * far
|
||||
half_x = half_y * aspect_ratio
|
||||
```
|
||||
|
||||
Количество частиц зависит от объёма и интенсивности. Вспомогательная
|
||||
формула использует округление FISTP к ближайшему чётному целому:
|
||||
|
||||
```text
|
||||
N = round_even(
|
||||
clamp(current_volume / reference_volume, 0, 1)
|
||||
* density
|
||||
* intensity
|
||||
* 1000
|
||||
)
|
||||
```
|
||||
|
||||
Текущий эмиттер передаёт `density = 1`. Публичный
|
||||
`PrecipitationVolume::particle_count_for` оставляет этот множитель явным для
|
||||
владельца, который хранит собственную плотность.
|
||||
|
||||
Порядок обновления частицы важен:
|
||||
|
||||
1. при создании три 15-битных значения детерминированного генератора
|
||||
заполняют локальный объём;
|
||||
2. локальная точка переводится через основу камеры в мировую позицию;
|
||||
3. мировая позиция интегрируется с мировым вектором скорости;
|
||||
4. для проверки границ позиция временно переводится обратно в локальные
|
||||
координаты, и к каждой оси применяется floor-based wrap;
|
||||
5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову.
|
||||
|
||||
Скорости не принадлежат системе координат камеры:
|
||||
|
||||
```text
|
||||
rain = [ 0.5, 0, -60 ]
|
||||
snow = [ 0.5, 0, -4 ]
|
||||
```
|
||||
|
||||
Это мировые векторы. Поворот камеры меняет область появления, wrap и
|
||||
проекцию, но не вращает уже движущуюся каплю вокруг наблюдателя. В
|
||||
`EnvironmentPrimitive::Particle` `position`, `velocity`, `world_head` и
|
||||
`world_tail` имеют мировые координаты.
|
||||
|
||||
Размер частиц состоит из коэффициента класса и масштаба погодного объекта:
|
||||
|
||||
```text
|
||||
rain_size = scalar * 0.0065
|
||||
snow_size = scalar * 0.0195
|
||||
```
|
||||
|
||||
`scalar` вычисляется из той же проекции, которую передаёт владелец кадра:
|
||||
|
||||
```text
|
||||
horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
|
||||
scalar = viewport_width / horizontal_fov
|
||||
```
|
||||
|
||||
Это соответствует запросу размера native camera (`Terrain` primary vtable
|
||||
slot `+6c`): ширина `RECT` делится на горизонтальный FOV в радианах. Поэтому
|
||||
`Camera::with_projection(...).with_viewport([width, height])` должен получать
|
||||
реальный viewport каждого кадра; отдельного setter-а масштаба нет.
|
||||
|
||||
`ScreenBillboard` содержит мировые голову и хвост, их проекцию и четыре NDC
|
||||
угла с UV. `half_size` остаётся native-величиной в пикселях, а обе глубины
|
||||
хранятся отдельно; renderer не смешивает эти единицы. Дождь использует
|
||||
предыдущую мировую голову как хвост и строит полосу перпендикулярно экранному
|
||||
направлению движения. Снег остаётся квадратом в текущей голове, использует
|
||||
исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает,
|
||||
как загрузить этот квадрат и как смешать его материал.
|
||||
|
||||
## Молния и звуковые события
|
||||
|
||||
`decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и
|
||||
2, первые четыре числа opcode 1 и полный body opcode 1. Имена не подменяются
|
||||
глобальным whitelist-ом: материал и звук принадлежат выбранному FXID.
|
||||
`LightningEffect` устанавливается через `set_lightning_effect`; без него
|
||||
погодный таймер не создаёт визуал или звук.
|
||||
|
||||
Активный объект использует следующий таймер. При интенсивности `I` сначала
|
||||
применяется `min(I, 0.95)`, затем для случайного `U` из 15-битного диапазона
|
||||
вычисляется задержка:
|
||||
|
||||
```text
|
||||
delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
|
||||
```
|
||||
|
||||
После достижения срока выбираются X и Y из переданных `LightningBounds`, а Z
|
||||
берётся без изменения. Если границы не заданы, используется позиция камеры.
|
||||
Следующая попытка разрешена через 6000 миллисекунд. Длительность уже
|
||||
созданного FX берётся из его заголовка. Renderer получает native descriptor
|
||||
`[40, 40, 600]`, начало эффекта на `sampled_z + 300`; при нулевом локальном
|
||||
смещении концы quad находятся на `sampled_z` и `sampled_z + 600`. Opcode 3
|
||||
задаёт материал, локальное смещение, scale и lifetime visual quad. Opcode 1
|
||||
вычисляется отдельно как динамический point light и не задаёт размеры или UV
|
||||
quad.
|
||||
CPU не подменяет numeric поля шириной или линейным lifetime fade.
|
||||
|
||||
Звуки возвращаются рядом с графикой как `SoundEvent`:
|
||||
|
||||
```text
|
||||
StartLoop -> начать дождевой loop
|
||||
SetLoopVolume -> обновить его громкость при интерполяции погоды
|
||||
StopLoop -> остановить loop
|
||||
OneShot -> воспроизвести разовый звук молнии
|
||||
```
|
||||
|
||||
Каждое событие содержит архив, имя, мировую позицию и линейную громкость.
|
||||
Для FX-звука также передаются native `min_distance`, `max_distance` и
|
||||
`frequency_ratio`; у дождевого loop используются `0`, бесконечность и `1`.
|
||||
Событие `OneShot` молнии использует ту же случайно выбранную позицию, что и
|
||||
визуальный эффект, а его диапазон и частота читаются из opcode 2: в
|
||||
поставленном `env_lightning` это `100`, `1500` и `1`. Audio backend загружает
|
||||
объявленный архив и запись лениво, кэширует проверенный sample, создаёт
|
||||
отдельный spatial source, применяет DirectSound range/pan и текущий listener.
|
||||
Отсутствие устройства вывода не должно останавливать симуляцию.
|
||||
|
||||
## Что остаётся за владельцем
|
||||
|
||||
`EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы
|
||||
или звуки и не выполняет финальный GPU pass. Владелец должен передать основу
|
||||
камеры, projection, реальный viewport, `SkyMaterials` и декодированный FXID.
|
||||
Из projection и viewport модуль сам получает native scalar осадков.
|
||||
|
||||
CPU-контракт проверяет движение, wrap, количество, время молнии и порядок
|
||||
звуковых переходов. Он не заявляет совместимость с нераскрытой семантикой
|
||||
всех FX opcode, с точным native RNG sequence или с конкретным mixer/device
|
||||
backend. Подробное описание связи с listener и mission audio находится в
|
||||
[томе VI](../tomes/06-behavior.md#погода-осадки-и-звуковые-события).
|
||||
@@ -38,13 +38,9 @@ MAT0 имеет type ID `0x3054414D`, обычно расположен в `Mate
|
||||
|
||||
```c
|
||||
#pragma pack(push, 1)
|
||||
struct Mat0PrefixV4Plus {
|
||||
struct Mat0Header {
|
||||
uint16_t phase_count;
|
||||
uint16_t animation_block_count;
|
||||
uint8_t metadata_a;
|
||||
uint8_t metadata_b;
|
||||
uint32_t metadata_c_raw;
|
||||
uint32_t metadata_d_raw;
|
||||
};
|
||||
|
||||
struct Phase34 {
|
||||
@@ -54,8 +50,78 @@ struct Phase34 {
|
||||
#pragma pack(pop)
|
||||
```
|
||||
|
||||
Versioned fields читаются только если версия их содержит. Для старых версий
|
||||
используются runtime defaults, а raw values сохраняются.
|
||||
`attr1` читается из NRes entry metadata и сохраняется вместе с разрешённым
|
||||
материалом. Это runtime flags материала; его нельзя путать с `attr2`, который
|
||||
задаёт версию payload. Versioned prefix bytes читаются только если версия их
|
||||
содержит, а raw values сохраняются для последующих слоёв.
|
||||
|
||||
### Pipeline category
|
||||
|
||||
Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в
|
||||
`LegacyPipelineState::with_material_category`. Таблица содержит пять
|
||||
подтверждённых категорий; helper возвращает `None` для остальных значений и не
|
||||
меняет уже выбранные depth и cull state.
|
||||
|
||||
| category | source/destination colour factors | alpha test |
|
||||
| ---: | --- | --- |
|
||||
| 0 | `ONE / ZERO`, blending disabled | disabled |
|
||||
| 1 | `SRC_ALPHA / INV_SRC_ALPHA` | enabled, `GREATER_EQUAL` |
|
||||
| 2 | `SRC_ALPHA / ONE` | enabled, `GREATER_EQUAL` |
|
||||
| 3 | `ZERO / SRC_COLOR` | enabled, `GREATER_EQUAL` |
|
||||
| 4 | `DEST_COLOR / SRC_COLOR` | enabled, `GREATER_EQUAL` |
|
||||
|
||||
The mapping comes from the native five render-state pairs selected by the
|
||||
material category. Alpha reference remains draw-range data, while the Vulkan
|
||||
pipeline key includes the blend mode, depth mode, cull mode and alpha-test
|
||||
variant so ranges with different native state do not share a pipeline.
|
||||
|
||||
Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени
|
||||
текстуры. Loader переводит параметры в коэффициенты так:
|
||||
|
||||
| bytes | runtime value |
|
||||
| --- | --- |
|
||||
| `p0..p2` | additive RGB, `p / 255` |
|
||||
| `p3` | opacity, `p * 0.01` |
|
||||
| `p4..p7` | directional values, `p / 255` |
|
||||
| `p8..p11` | specular values, `p / 255` |
|
||||
| `p12..p15` | extra values, `p / 255` |
|
||||
| `p16` | integer power |
|
||||
| `p17` | signed TEXM page index |
|
||||
|
||||
Texture name и page index выбираются из текущей phase. Они не смешиваются с
|
||||
соседней phase.
|
||||
|
||||
## Animation
|
||||
|
||||
После phase table идут `animation_block_count` плотных блоков. Каждый блок имеет
|
||||
`u32 header_raw`, `u16 key_count`, затем `key_count` записей по три `u16`:
|
||||
|
||||
```text
|
||||
u32 header_raw
|
||||
u16 key_count
|
||||
repeat key_count:
|
||||
u16 phase_index
|
||||
u16 end_time_ms
|
||||
u16 raw_k2
|
||||
```
|
||||
|
||||
`header_raw & 7` задаёт режим: `0` loop, `1` ping-pong, `2` clamp, `3`
|
||||
random-per-query. Значения `4..7` сохраняются как unknown. `header_raw >> 3`
|
||||
является interpolation mask: bits `1`, `2`, `4`, `8` смешивают группы
|
||||
`p0..p2`, `p4..p6`, `p8..p10`, `p12..p14`, а bit `16` смешивает `p3`.
|
||||
Компоненты `p7`, `p11`, `p15`, `p16` и `p17` копируются из текущей phase.
|
||||
|
||||
Время для блока вычисляется как `clock_ms - wear_row_start_ms` с wrapping
|
||||
`u32` subtraction. Для loop берётся остаток от последнего `end_time_ms`, для
|
||||
ping-pong нечётный цикл идёт в обратном направлении, clamp после duration
|
||||
выбирает последнюю phase, а random-per-query использует переданное случайное
|
||||
значение по модулю duration. Ключ выбирается по интервалу
|
||||
`previous_end <= local_time < end_time`; следующий ключ циклически замыкается на
|
||||
первый. На ping-pong turnaround и clamp после duration native routine проходит
|
||||
общий float interpolation path, включая unsigned subtraction endpoint arithmetic;
|
||||
это может дать extrapolated coefficients при ненулевой mask. Переданный в
|
||||
sampler `animation_block_index` уже выбран вызывающим runtime из block table;
|
||||
packed WEAR handle и номер строки WEAR являются отдельными значениями.
|
||||
|
||||
## Fallback
|
||||
|
||||
|
||||
+79
-21
@@ -19,7 +19,7 @@ type 10 strings and node names
|
||||
type 13 Batch20 records
|
||||
type 15 auxiliary stream
|
||||
type 17 auxiliary data
|
||||
type 18 rare stream
|
||||
type 18 packed UV1 (optional secondary coordinates)
|
||||
type 19 animation frame map
|
||||
type 20 rare auxiliary table
|
||||
```
|
||||
@@ -40,23 +40,77 @@ struct Node38 {
|
||||
};
|
||||
```
|
||||
|
||||
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
|
||||
отсутствие геометрии для комбинации LOD/group.
|
||||
В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`.
|
||||
Публичные Rust-обёртки пока сохраняют исторические имена `Lod` и `Group`,
|
||||
поэтому в `selected_slot` аргумент `Lod` задаёт native state (0..2), а
|
||||
`Group` задаёт native LOD (0..4). `0xFFFF` означает отсутствие геометрии для
|
||||
комбинации state/LOD.
|
||||
|
||||
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
|
||||
Validated `ModelAsset` сохраняет decoded type 8 keys и type 19 map как
|
||||
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
|
||||
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
|
||||
иначе это parent index, обязательно меньший индекса child. Этот контракт
|
||||
подтверждён на тестах анимации с оригинальными ресурсами обеих частей и защищён fallback-ом:
|
||||
модель с нарушенным порядком не получает придуманную hierarchy.
|
||||
а `node38_fallback_hierarchy` собирает parent-before-child hierarchy.
|
||||
`node38_sampled_hierarchy` использует type 19 map в пределах объявленного
|
||||
числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map
|
||||
или выходе за frame count используется fallback key. Для покомпонентного
|
||||
sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float
|
||||
time для каждого узла: `Some(time)` выбирает type 19 frame по округлению
|
||||
`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный
|
||||
type 8 key и его следующий key в исходный float time; `None` использует
|
||||
fallback key. Значения должны быть конечными и неотрицательными. Это
|
||||
portable-поведение при default-nearest rounding; полного совпадения со всеми
|
||||
режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF`
|
||||
означает root, иначе это parent index, который должен быть меньше индекса
|
||||
child. Модель с нарушенным порядком не получает придуманную hierarchy.
|
||||
|
||||
В legacy-camera static preview стандартный узел уже получает свой fallback pose
|
||||
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
|
||||
затем translation суммируется, а rotations умножаются; после полученной global
|
||||
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
|
||||
намеренно дублируется на draw-range узла, потому что один source vertex может
|
||||
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
|
||||
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
|
||||
Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит
|
||||
его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32
|
||||
`g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active
|
||||
`Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь
|
||||
Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и
|
||||
до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback
|
||||
poses применяется та же граница. Общий animation decoder и другие форматы
|
||||
анимации не изменяются.
|
||||
|
||||
В legacy-camera static preview без `--static-animation-frame <u16>` используются
|
||||
сохранённые начальные значения из явной ссылки каждого prototype на `.ctl`.
|
||||
Узлы без control binding получают time `0`. Для каждой component instance
|
||||
обрабатываются её CTLD bindings, поэтому общий deduplicated MSH не смешивает
|
||||
controls разных компонентов. Строки идут в source order, и последняя включённая
|
||||
строка для одного узла заменяет предыдущую. Flag `0x01` выполняет один wrap
|
||||
значения blend на единицу, иначе значение ограничивается `[0, 1]`; flag `0x02`
|
||||
инвертирует результат (`1 - blend`), а строки с `0x04` пропускаются. Выбранный
|
||||
float time равен `(1 - blend) * frame_a + blend * frame_b`. Если он выходит за
|
||||
type 19 map, MSH sampler использует fallback key; если после одной wrap/clamp и
|
||||
invert получается отрицательное или нечисловое время, preview завершится
|
||||
понятной ошибкой. Явный `--static-animation-frame` остаётся global override
|
||||
для всех компонентов. Parent pose поворачивает child translation, затем
|
||||
translation суммируется, а rotations умножаются; после полученной global pose
|
||||
применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия намеренно
|
||||
дублируется на draw-range узла, потому что один source vertex может быть
|
||||
нарисован разными node poses. Это только начальная статическая pose: она не
|
||||
запускает игровой Control/AI и не обещает полного совпадения с runtime или
|
||||
всеми x87 rounding modes.
|
||||
|
||||
## Vertex streams
|
||||
|
||||
Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
|
||||
задаёт количество вершин, а `attr3` — размер записи в байтах. Type 4
|
||||
содержит четыре signed bytes нормали. Type 5 и optional type 18 содержат по
|
||||
два little-endian `uint16` на вершину:
|
||||
|
||||
```c
|
||||
struct PackedUv16x2 {
|
||||
uint16_t u;
|
||||
uint16_t v;
|
||||
};
|
||||
```
|
||||
|
||||
Type 5 — primary UV0, type 18 — secondary UV1 для lightmap/detail stage.
|
||||
Для type 18 loader требует `attr3 == 4`, совпадение `attr1` и длины payload,
|
||||
а также одинаковое число записей с type 3. Обе пары декодируются как
|
||||
`packed / 1024.0`; renderer обращается к UV1 по тому же `source_index`, что и
|
||||
к UV0. Отсутствующий type 18 означает, что secondary stage не имеет authored
|
||||
координат.
|
||||
|
||||
## Slot and batch
|
||||
|
||||
@@ -82,16 +136,20 @@ Type 13 задаёт draw ranges:
|
||||
#pragma pack(push, 1)
|
||||
struct Batch20 {
|
||||
uint16_t batch_flags;
|
||||
uint16_t material_index;
|
||||
uint16_t opaque4;
|
||||
uint16_t opaque6;
|
||||
uint16_t material_index_hi; // +0x02, native high selector/slot field
|
||||
uint8_t material_index; // +0x04, WEAR material selector
|
||||
uint8_t lightmap_index; // +0x05, 0xFF means no lightmap
|
||||
uint16_t local_batch_index; // +0x06
|
||||
uint16_t index_count;
|
||||
uint32_t index_start;
|
||||
uint16_t opaque14;
|
||||
uint16_t vertex_count; // +0x0E
|
||||
uint32_t base_vertex;
|
||||
};
|
||||
#pragma pack(pop)
|
||||
```
|
||||
|
||||
Index check выполняется как `base_vertex + index < vertex_count` для всего
|
||||
используемого slice.
|
||||
Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
|
||||
отдельно для native overrides. Loader проверяет, что
|
||||
`index_start + index_count` остаётся внутри type 6, а каждый использованный
|
||||
индекс через `base_vertex` попадает в position stream. `vertex_count` и
|
||||
`local_batch_index` доступны renderer для исходного draw contract.
|
||||
|
||||
@@ -69,6 +69,14 @@ projection matrix contract.
|
||||
|
||||
## Parity risks
|
||||
|
||||
`VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection
|
||||
reconstruction in `fparkan-render`, then negates the vertical projection term
|
||||
once at the Vulkan adapter boundary. D3D7's top-down viewport places positive
|
||||
NDC Y at the top; the Vulkan swapchain uses a positive viewport height, so the
|
||||
adapter flips NDC Y while the shader preserves `gl_Position`. The generic
|
||||
`from_row_major_view_projection` path remains unchanged; its callers supply
|
||||
Vulkan-ready matrices, as the free-flight camera already does.
|
||||
|
||||
- x87 precision and rounding;
|
||||
- scalar/SIMD `g_FastProc` differences;
|
||||
- object, batch and transparent primitive order;
|
||||
|
||||
+55
-19
@@ -359,9 +359,16 @@ local.set_translation(pose.position);
|
||||
world[n] = world[parent(n)] * local;
|
||||
```
|
||||
|
||||
Для parity особенно важны x87-compatible округление при выборе frame index и
|
||||
порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле
|
||||
границы.
|
||||
Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа
|
||||
на CTLD `.ctl`. Используются только включённые строки, а при повторе node ID
|
||||
поздняя строка заменяет раннюю. Без `--static-animation-frame` preview применяет
|
||||
эти настройки узлов; узлы без настройки остаются на кадре `0`. Явный
|
||||
`--static-animation-frame N` переопределяет их и задаёт `N` всем узлам.
|
||||
Результат — одна статическая поза, не обновление анимации, Control или AI.
|
||||
MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему
|
||||
целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий
|
||||
key в исходном `time`; это portable-вариант native x87 conversion в default
|
||||
rounding mode, без обещания совпадения при любом x87 control word.
|
||||
|
||||
Проверки animation data:
|
||||
|
||||
@@ -611,11 +618,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж
|
||||
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
|
||||
|
||||
Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и
|
||||
роботов, ограничения максимального числа, detail level и smoothing. Доказаны
|
||||
shadow manager/pass, настройки detail/smoothing/count и зависимость от
|
||||
Terrain/CShade; полная формула projection geometry для каждого caster требует
|
||||
dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по
|
||||
именам, а не заменяются произвольными modern defaults.
|
||||
роботов, ограничения максимального числа, detail level и smoothing. Реализованы
|
||||
native visibility по шести Vulkan clip-плоскостям с радиусом sphere +100,
|
||||
порядок кандидатов и лимит 20 до построения страниц, projected-size LOD,
|
||||
directional/point light contribution, native page layout, raster, smoothing и
|
||||
receiver projection с edge clipping. Unknown settings из `shade.cfg` читаются и
|
||||
сохраняются по именам, а не заменяются произвольными modern defaults. Для native ray query
|
||||
surface descriptor в `+0x10` требует наличия всех переданных bits, а descriptor
|
||||
в `+0x14` исключает поверхности с любым переданным bit. Общий shadow query
|
||||
использует full exclusion mask `0x2000`; actor four-ray path получает compact
|
||||
mask `0x8008`, который landscape boundary разворачивает в full mask
|
||||
`0x00200020`. Sun path передаёт full `0x20`, кодирует его как compact bit `8`
|
||||
и затем получает тот же full bit после разворачивания. Hit record хранит в
|
||||
`+0x20` квадрат евклидовой длины луча, поэтому native four-ray fade использует
|
||||
`distance² * 1e-4`, а не линейную дальность.
|
||||
|
||||
Atmosphere manager создаёт world objects для фоновых и погодных явлений.
|
||||
Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain
|
||||
@@ -624,6 +640,9 @@ background sound и обязательные ссылки на lightning effect.
|
||||
требует screen position и occlusion test, rain -- области рядом с observer,
|
||||
sound -- listener. Их нельзя один раз запечь в terrain.
|
||||
|
||||
Sun occlusion начинается от точки `camera + 0.5 * normalize(sun - camera)`;
|
||||
этот offset является частью native query, а не произвольным bias renderer-а.
|
||||
|
||||
RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок.
|
||||
Даже правильный средний интервал не даёт повторяемый кадр, если random values
|
||||
запрашиваются в другой последовательности.
|
||||
@@ -677,11 +696,14 @@ settings ID сохраняются.
|
||||
|
||||
```text
|
||||
opcode = command_word & 0xFF
|
||||
enabled = (command_word >> 8) & 1
|
||||
native_flag = (command_word >> 8) & 1
|
||||
```
|
||||
|
||||
Bits 9-31 являются частью данных и сохраняются. Между командами нет
|
||||
выравнивания. Размер команды, включая word:
|
||||
`native_flag` передаётся созданному native command object как режим/feature
|
||||
flag и не отключает команду. Например, реальный `env_lightning` использует
|
||||
`native_flag == 0` для opcode 3, opcode 1 и opcode 2, и все три команды
|
||||
исполняются. Bits 9-31 являются частью данных и сохраняются. Между командами
|
||||
нет выравнивания. Размер команды, включая word:
|
||||
|
||||
```text
|
||||
opcode 1 224 байта
|
||||
@@ -896,8 +918,11 @@ Shade cache в GOG `Terrain.dll` имеет vtable RVA `0x643D0`. Его таб
|
||||
находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup
|
||||
RVA `0x10910` возвращает временное представление по `+24`, а не саму запись.
|
||||
Построение в RVA `0x10280` и `0x12e20` использует созданную загрузчиком геометрию.
|
||||
Поэтому повторить terrain shader только разбором WEAR нельзя. Точная композиция
|
||||
слоёв и микротекстур остаётся открытым вопросом.
|
||||
В рабочем Vulkan-пути `TerrainMaterialLayers` разрешает для каждого terrain slot
|
||||
базовую, detail, overlay и overlay-detail фазы из WEAR/MAT0; их UV, alpha и
|
||||
lightmap state передаются в `VulkanStaticMaterial`, а shader последовательно
|
||||
сэмплирует эти четыре стадии. Animation clock обновляет выбранные фазы и
|
||||
коэффициенты без пересборки terrain mesh, сохраняя native type-14 overlay mask.
|
||||
|
||||
## Реализация Vulkan
|
||||
|
||||
@@ -905,12 +930,23 @@ RVA `0x10910` возвращает временное представление
|
||||
получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через
|
||||
push constant; он не требует нового pipeline.
|
||||
|
||||
Статический путь `fparkan-game` загружает ландшафт и MSH-компоненты выбранных
|
||||
объектов. Локальные material slots разрешаются через WEAR и MAT0 перед
|
||||
объединением геометрии. Индексы GPU имеют тип `u32`: вся карта может содержать
|
||||
больше 65 535 вершин, даже если каждый исходный mesh использует `u16`.
|
||||
Текущий путь берёт базовую текстуру Land2; свет, составные слои и атмосфера
|
||||
пока не участвуют в этом статическом изображении.
|
||||
Рабочий путь `fparkan-game` загружает terrain и все выбранные mission roots
|
||||
(по умолчанию весь список объектов), разрешая локальные material slots через
|
||||
WEAR и MAT0 до объединения геометрии. Индексы GPU имеют тип `u32`: вся карта
|
||||
может содержать больше 65 535 вершин, даже если каждый исходный mesh использует
|
||||
`u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и
|
||||
animation phase; object batches сохраняют их legacy blend/depth/alpha state.
|
||||
|
||||
После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
|
||||
`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
|
||||
кадре собирает environment frame. Mission-local `sky.ske`/`sky.wea` выбирают
|
||||
атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
|
||||
lightning visual; weather particles, flares, sun/moon, point lights,
|
||||
projected shadows и audio events обновляются вместе с камерой и временем.
|
||||
`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
|
||||
бесконечным, а `--preview-roots` служит только диагностическим ограничителем.
|
||||
`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
|
||||
управления.
|
||||
|
||||
Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer
|
||||
копирует последний отправленный swapchain image в host-visible buffer и
|
||||
|
||||
+169
-34
@@ -143,7 +143,7 @@ references каждого record, жёстко ограничивает counts/a
|
||||
файла, но не таблица семантик: названия opcode/words появятся только после
|
||||
handler contracts и runtime traces.
|
||||
|
||||
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
|
||||
Связь первого header word с dispatch доказана статически: `ai.dll`
|
||||
создаёт 73 handler pointers в известном порядке и копирует table без
|
||||
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
|
||||
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
|
||||
@@ -201,16 +201,6 @@ Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evalu
|
||||
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
|
||||
или связь чисел tag с языковыми операторами.
|
||||
|
||||
Выгрузка воспроизводится без изменения PE:
|
||||
|
||||
```powershell
|
||||
& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
|
||||
C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
|
||||
-processor x86:LE:32:default `
|
||||
-scriptPath C:\Develop\fparkan\tools\ghidra `
|
||||
-postScript ExportAiExpressionDispatcher.java -deleteProject
|
||||
```
|
||||
|
||||
### TRF и preload-данные
|
||||
|
||||
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
|
||||
@@ -292,9 +282,7 @@ Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI фор
|
||||
а mode передаётся последним; decompiler не восстанавливает предметные имена
|
||||
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
|
||||
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
|
||||
`0x42478`. Reproducible extractor находится в
|
||||
`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
|
||||
в локальном Ghidra project и не изменяет оригинальную DLL.
|
||||
`0x42478`.
|
||||
|
||||
Именно update methods этих private objects, а не пять exports, остаются
|
||||
следующим объектом динамической трассировки. Поэтому reference movement в
|
||||
@@ -330,8 +318,7 @@ interface сразу получает пять virtual calls, связывающ
|
||||
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
|
||||
связывается с `+0x170`. Это достаточное основание хранить будущий Control
|
||||
component как ordered raw-string/resource provenance, но не для присвоения
|
||||
этим строкам смысловых имён до трассировки private update methods. Extractor:
|
||||
`tools/ghidra/ExportAniMeshControlCaller.java`.
|
||||
этим строкам смысловых имён до трассировки private update methods.
|
||||
|
||||
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
|
||||
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
|
||||
@@ -393,22 +380,36 @@ Collision manager не должен хранить прямую незащищё
|
||||
### CTLD и physical resources
|
||||
|
||||
Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources.
|
||||
В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять
|
||||
внутренних счётчиков образуют множество вариантов: наиболее частый размер
|
||||
392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до
|
||||
1868 байт и более сложные комбинации.
|
||||
В заголовке CTLD идут пять `u32`; обозначим первые три counts как `S`, `M` и
|
||||
`T`. Native layout задаёт начало control-row table формулой
|
||||
`128 + S * (156 + 16 * M) + 4 * S * S`; за ним следуют `T` records по 36 байт.
|
||||
В record известны node ID (`i32`, `+0`), два endpoint frame (`f32`, `+4`, `+8`),
|
||||
начальный blend (`f32`, `+0x0c`) и raw flags (`u32`, `+0x20`). Остальные поля
|
||||
нельзя выводить из этой позовой привязки.
|
||||
|
||||
CTLD является составным count-driven форматом, а не фиксированной struct.
|
||||
Parser должен:
|
||||
Пример `fr_l_plant.ctl` имеет counts `[14, 0, 5, 11, 13]`: формула даёт
|
||||
`row_start = 3096 (0xC18)`, а пять control rows заканчиваются на `+0xCCC`.
|
||||
GOG `Control.dll` function RVA `0x9950` читает blend из `+0x0c` и flags из
|
||||
`+0x20`. Его branch RVA `0x99C4..0x9A66` применяет flags: при `flags & 0x1`
|
||||
blend один раз переносится через границу — из значения выше `1` вычитается
|
||||
`1`, к значению ниже `0` прибавляется `1`; без этого флага blend ограничивается
|
||||
`[0, 1]`. Затем `flags & 0x2` инвертирует значение (`1 - blend`). Константы
|
||||
`Control.dll+0x3B188 = 1.0` и `+0x3B18C = 0.0` подтверждают границы этих
|
||||
сравнений.
|
||||
|
||||
- прочитать prefix и все счётчики с проверкой переполнения;
|
||||
- вычислить границы секций по их counts;
|
||||
- сохранять неизвестные records в typed raw containers;
|
||||
- требовать точного завершения payload;
|
||||
- не использовать размер одного популярного варианта как универсальный layout.
|
||||
|
||||
Полная предметная семантика всех секций ещё не доказана, но существующие файлы
|
||||
можно безопасно читать, индексировать и сохранять.
|
||||
Control в call site RVA `0x99B5` передаёт в AniMesh через slot `+0x30` пару A
|
||||
`[-1, -1]` и пару B из endpoint полей строки. В call site `0x9A7B` slot `+0x28`
|
||||
сохраняет нормализованный blend по `+0x114` и weight `1` по `+0x118`. AniMesh
|
||||
update RVA `0x8BF2..0x8C76` вычисляет
|
||||
`frame = (1 - blend) * endpoint_a + blend * endpoint_b`; update RVA `0x12560`
|
||||
выбирает B-state из-за weight `1`. В static preview строки с `flags & 0x4 != 0`
|
||||
пропускаются до проверки float полей, поскольку endpoint поля в них могут быть
|
||||
sentinel `-1`. Повторные node ID сохраняют исходный порядок, поэтому последняя
|
||||
включённая строка задаёт кадр узла. Привязки сохраняются отдельно для каждого
|
||||
экземпляра компонента, даже когда `PreparedVisual` разделяется через cache.
|
||||
Это даёт начальную позу preview, но не запускает игровой Control, AI или полный
|
||||
runtime animation path. Остальные
|
||||
секции CTLD shape records и contact solver не входят в эту интерпретацию.
|
||||
|
||||
### Terrain queries и movement handoff
|
||||
|
||||
@@ -452,10 +453,8 @@ exports; RVA всех пяти exports изменились. Форматы и c
|
||||
сохранились, но точное physical/collision behavior нельзя считать побайтно тем
|
||||
же.
|
||||
|
||||
CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено;
|
||||
большинство общих CTLD изменено вместе с переработанными моделями. Это
|
||||
подтверждает count-driven parser, но не закрывает предметную семантику shape
|
||||
records и contact solver.
|
||||
Покадровая привязка выше описывает начальную позу компонента. Она не задаёт
|
||||
семантику CTLD shape records и не восстанавливает contact solver.
|
||||
|
||||
Differential test обеих частей должен воспроизводить движение без препятствий,
|
||||
slope following, pair collision, timing collision event и удаление объекта в
|
||||
@@ -569,6 +568,142 @@ Ngi32 создаёт низкоуровневый DirectSound backend. `services
|
||||
`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы:
|
||||
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera.
|
||||
|
||||
### Погода, осадки и звуковые события
|
||||
|
||||
Погода для игрока — это одновременно движущиеся точки на экране и звуковой
|
||||
фон. Расписание `sky.ske` говорит, в какой момент действует дождь, снег или
|
||||
молния, а `sky.wea` назначает имена материалов. Система окружения каждый
|
||||
кадр превращает это состояние в два списка: мировые частицы для renderer-а и
|
||||
звуковые переходы для audio backend-а.
|
||||
|
||||
```text
|
||||
sky.ske + sky.wea
|
||||
-> состояние погоды
|
||||
-> EnvironmentFrame
|
||||
-> Particle / Lightning (renderer)
|
||||
-> StartLoop, SetLoopVolume, StopLoop, OneShot (sound)
|
||||
```
|
||||
|
||||
Такое разделение нужно для понятной границы ответственности. Система погоды
|
||||
решает, **что** произошло и где находится источник. Renderer решает, как
|
||||
нарисовать прозрачный квадрат или молнию. Audio backend разрешает архив и
|
||||
имя, создаёт источник звука и сравнивает его с текущим listener.
|
||||
|
||||
У осадков есть объём перед наблюдателем. Эталонные границы имеют глубину
|
||||
`2..50`, половины углов `0.65` и `0.4875` радиана. Для текущей камеры:
|
||||
|
||||
```text
|
||||
half_y = tan(vertical_fov / 2) * 50
|
||||
half_x = half_y * aspect_ratio
|
||||
```
|
||||
|
||||
Размер осадков получает тот же camera query, что и native `Terrain`: ширина
|
||||
viewport делится на горизонтальный FOV в радианах.
|
||||
|
||||
```text
|
||||
horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
|
||||
precipitation_scalar = viewport_width / horizontal_fov
|
||||
rain_size = precipitation_scalar * 0.0065
|
||||
snow_size = precipitation_scalar * 0.0195
|
||||
```
|
||||
|
||||
Число точек получает масштабирование по отношению текущего объёма к
|
||||
эталонному и округляется к ближайшему чётному целому:
|
||||
|
||||
```text
|
||||
N = round_even(clamp(current_volume / reference_volume, 0, 1)
|
||||
* density * intensity * 1000)
|
||||
```
|
||||
|
||||
В рабочем эмиттере `density` равна единице. Точка сначала появляется в
|
||||
локальных координатах этого объёма, затем получает мировую позицию. Дождь
|
||||
движется с мировым вектором `[0.5, 0, -60]`, снег — `[0.5, 0, -4]`.
|
||||
Поворот камеры меняет видимую область и проекцию, но не вращает эти векторы.
|
||||
Когда точка пересекает грань, она переводится в локальные координаты,
|
||||
циклически переносится на противоположную грань и возвращается в мир.
|
||||
|
||||
Поэтому источник звука и положение частицы должны храниться в мировых
|
||||
координатах. У дождя хвостом экранной полосы становится предыдущая мировая
|
||||
позиция; у снега остаётся квадрат в текущей позиции. Scalar вычисляется из
|
||||
projection и реального viewport каждого кадра, поэтому отдельная настройка
|
||||
размера не нужна.
|
||||
|
||||
Дождевой loop следует жизненному циклу состояния:
|
||||
|
||||
| Событие | Действие audio backend |
|
||||
| --- | --- |
|
||||
| `StartLoop` | открыть объявленный sample и начать пространственный loop |
|
||||
| `SetLoopVolume` | сохранить loop и применить новую интенсивность как громкость |
|
||||
| `StopLoop` | остановить текущий loop |
|
||||
| `OneShot` | создать отдельный источник и воспроизвести sample молнии один раз |
|
||||
|
||||
Интенсивность между ключами `sky.ske` интерполируется, поэтому `SetLoopVolume`
|
||||
может приходить на каждом кадре. Имя из расписания сохраняется. Если указано
|
||||
только `atm_rain1.wav`, архив остаётся пустым и audio owner использует
|
||||
библиотеку миссии; запись `archive/name` задаёт архив явно. Ресурс загружается
|
||||
лениво и кэшируется после проверки, чтобы не читать все возможные погодные
|
||||
звуки при запуске миссии.
|
||||
|
||||
Молния использует отдельный таймер. Для интенсивности `I` и случайного `U` из
|
||||
15-битного диапазона задержка имеет вид
|
||||
|
||||
```text
|
||||
delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
|
||||
```
|
||||
|
||||
После срока выбираются мировые X и Y из `LightningBounds`, а Z копируется из
|
||||
границ. Затем объект ждёт 6000 миллисекунд перед новой попыткой. Визуальный
|
||||
контракт передаёт renderer-у material и numeric body opcode 3; native
|
||||
descriptor `[40, 40, 600]` начинается на `sampled_z + 300`. При нулевом
|
||||
локальном смещении его концы находятся на `sampled_z` и `sampled_z + 600`.
|
||||
Opcode 1 в это же время отдельно обновляет point light; он не задаёт размеры
|
||||
или UV quad. Звуковой `OneShot` использует ту же позицию, а его opcode 2
|
||||
параметры `min_distance=100`, `max_distance=1500`, `frequency_ratio=1`
|
||||
проходят в spatial source. Renderer и audio backend применяют numeric FX
|
||||
поля и lifetime из заголовка эффекта, а CPU не подменяет их собственной
|
||||
шириной или fade-кривой.
|
||||
|
||||
Подробные поля `EnvironmentFrame`, правила wrap и границы CPU-модуля собраны
|
||||
в [справочнике эффектов окружения](../reference/environment-effects.md).
|
||||
|
||||
### Ambient variations и переход день/ночь
|
||||
|
||||
`ambient_music_loop` запускает `THEME` сразу после открытия миссии. Вариации
|
||||
не выбираются последовательным счётчиком: audio owner получает `dt_seconds`
|
||||
как приращение времени кадра и после строгого условия `elapsed > delay`
|
||||
выбирает один sample.
|
||||
Первый положительный tick поэтому запускает первую вариацию, а задержка между
|
||||
следующими попытками равна `10 + rand() % 10` секунд.
|
||||
|
||||
В `ambient_music_variation` поддерживаются три независимых пула:
|
||||
|
||||
| Пул | Ключи | Когда выбирается |
|
||||
| --- | --- | --- |
|
||||
| default | `DEFAULT_VARIATION1..n` | когда отсутствуют оба пула `DAY` и `NIGHT` |
|
||||
| day | `DAY_VARIATION1..n` | длина базового RGB активного небесного объекта больше `1.1` |
|
||||
| night | `NIGHT_VARIATION1..n` | длина базового RGB активного небесного объекта не больше `1.1` |
|
||||
|
||||
Базовый RGB передаётся до camera-dependent glare и берётся у первого активного
|
||||
небесного объекта. Если существует хотя бы один day/night пул, выбранный
|
||||
пустой пул остаётся пустым: он не заменяется default или противоположным пулом.
|
||||
`LIBRARY` у `ambient_music_variation` может отличаться от библиотеки theme;
|
||||
если поле отсутствует, используется библиотека loop.
|
||||
|
||||
Индекс выбирается двумя 16-битными состояниями Iron3D:
|
||||
|
||||
```text
|
||||
a = (a << 1) xor b
|
||||
b = (b >> 1) xor a
|
||||
index = b % pool_length
|
||||
```
|
||||
|
||||
При длине пула больше одного предыдущий индекс отбрасывается одной или более
|
||||
повторными выборками. Последний индекс сохраняется при переходе между day и
|
||||
night; для пустого пула sample не создаётся, но следующий таймер продолжает
|
||||
работать. При пустом пуле native сбрасывает индекс в `-1`. Пауза окна сохраняет
|
||||
логическое состояние таймера и индекса, поэтому
|
||||
возобновление не перескакивает на случайную вариацию.
|
||||
|
||||
Публичные функции Ngi32:
|
||||
|
||||
```text
|
||||
|
||||
@@ -28,8 +28,9 @@ FParkan развивается небольшими законченными и
|
||||
|
||||
Оригинальные ресурсы остаются в установленной игре. Локальные тесты с ними
|
||||
помечены `#[ignore]`, чтобы обычная проверка работала без коммерческих файлов.
|
||||
Сообщение об ошибке должно назвать ресурс и причину: например, какой материал
|
||||
сослался на отсутствующую текстуру. Отдельный отчёт для каждого запуска не нужен.
|
||||
Сообщение об ошибке должно сразу назвать ресурс и причину: например, какой
|
||||
материал сослался на отсутствующую текстуру. Поэтому диагностика остаётся
|
||||
частью самого запуска и не зависит от отдельного отчёта.
|
||||
|
||||
## Числа и порядок вычислений
|
||||
|
||||
|
||||
+18
-77
@@ -241,45 +241,20 @@ near/far mapping, handedness или initial camera selection. Важно, что
|
||||
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
|
||||
stubs и не дают usable ABI.
|
||||
|
||||
Live elevated read-only probe впервые подтвердил relocation-aware runtime связь:
|
||||
`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал
|
||||
non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно
|
||||
relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает
|
||||
live camera object с outer vtable, но одновременно исправляет прежнее слишком
|
||||
сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix,
|
||||
а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
|
||||
`0x4D9C0` и exported global ещё не доказаны как один layout без interface
|
||||
adjustment; их offsets остаются unassigned до recovery selector relationship.
|
||||
Глобальная camera boundary имеет более точное статическое описание.
|
||||
`stdGetCurrentCamera2` — короткий getter Terrain по RVA `0x4FD80`, который
|
||||
читает указатель из global RVA `0x7355C`; initializer по RVA `0x4D4D0`
|
||||
запрашивает selector `8` у своего `this` и сохраняет полученный interface
|
||||
pointer. Запрос selector `18` у landscape object проходит через RVA `0x106D0`
|
||||
и `0x107E0`, возвращает subobject по `base + 0x138`.
|
||||
|
||||
Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это
|
||||
шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA
|
||||
`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain
|
||||
по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет
|
||||
полученный interface pointer. Это доказывает адрес хранения и путь заполнения,
|
||||
но не разрешает трактовать pointer как конкретный layout камеры либо читать его
|
||||
как runtime evidence без доступа к процессу на том же уровне привилегий.
|
||||
|
||||
Elevated live sampling теперь доказывает, что direct static xref не исчерпывает
|
||||
runtime writers: за 25 секунд autoplay global переключился между тремя heap
|
||||
pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов
|
||||
paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли
|
||||
world-like translation, например `(491.562, 761.551, 7.361)`; третий давал
|
||||
normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block.
|
||||
Наблюдение согласуется с автоматическими camera switches, но не маркирует mode;
|
||||
оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery
|
||||
indirect/unanalyzed write path.
|
||||
|
||||
Outer vtable `off_100665B4` теперь даёт exact transform adjustment для
|
||||
world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20`
|
||||
с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте
|
||||
selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при
|
||||
этом возвращает `outer + 0x20`. Значит observed triple
|
||||
`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine
|
||||
transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`;
|
||||
outer slot `+0x70` применяет `atan2` к его axis values, что доказывает
|
||||
orientation-angle path. Названия полей, angle order и handedness пока не
|
||||
установлены, но raw affine transform и translation можно сохранять как
|
||||
backend-neutral camera pose без догадок.
|
||||
Внешняя camera vtable по RVA `0x665B4` имеет slot `+0x54`, который вызывает у
|
||||
subobject `outer + 0x4` slot `+0x20` с selector `0` и копирует возвращённые
|
||||
компоненты `+0x0C`, `+0x1C` и `+0x2C`. При значении selector field
|
||||
`outer + 0x10 = -1` selector `0` возвращает `outer + 0x20`; selector `2`
|
||||
возвращает `outer + 0x60`, а slot `+0x70` выводит углы через `atan2`.
|
||||
Это фиксирует пути чтения положения и ориентации, но не назначает имена полям,
|
||||
порядок углов или handedness.
|
||||
|
||||
### Vtable и interface negotiation
|
||||
|
||||
@@ -288,6 +263,11 @@ backend-neutral camera pose без догадок.
|
||||
world traversal; camera и viewport получаются через selector-based interface
|
||||
calls; shared objects используют ранний slot как AddRef-подобную операцию.
|
||||
|
||||
Запрос selector `18` у landscape object проходит через RVA `0x106D0` и
|
||||
`0x107E0`, возвращает subobject по `base + 0x138`, а его vtable slot `+0x18`
|
||||
ведёт через RVA `0x14230` к обработчику `0x127D0`. Это geometry-interface
|
||||
boundary, отдельная от хранения активной camera.
|
||||
|
||||
Правила реконструкции:
|
||||
|
||||
1. Зафиксировать byte offset slot и число аргументов.
|
||||
@@ -299,45 +279,6 @@ calls; shared objects используют ранний slot как AddRef-по
|
||||
Нельзя добавлять virtual destructor в начало reconstructed interface: это
|
||||
сдвинет все slots.
|
||||
|
||||
### ABI-матрица Частей 1 и 2
|
||||
|
||||
Во всех пятнадцати DLL совпадают export names, ordinals и import sets. Общее
|
||||
число exports остаётся 313. Обе полные части содержат 1 134 imported function
|
||||
slots; значение 1 126 относится к демоверсии и хранится отдельно.
|
||||
|
||||
Побайтно идентичны девять DLL:
|
||||
|
||||
```text
|
||||
ai.dll
|
||||
Behavior.dll
|
||||
Joystick.dll
|
||||
MisLoad.dll
|
||||
Net.dll
|
||||
Ngi32.dll
|
||||
Terrain.dll
|
||||
Wizard.dll
|
||||
World3D.dll
|
||||
```
|
||||
|
||||
Пересобраны `AniMesh.dll`, `ArealMap.dll`, `Control.dll`, `Effect.dll`,
|
||||
`iron3d.dll`, `services.dll`.
|
||||
|
||||
Изменение export RVA:
|
||||
|
||||
```text
|
||||
AniMesh 2 / 2
|
||||
Control 5 / 5
|
||||
iron3d 8 / 8
|
||||
services 6 / 6
|
||||
ArealMap 0 / 9
|
||||
Effect 0 / 2
|
||||
```
|
||||
|
||||
Нулевое изменение export RVA не доказывает идентичность тела функции:
|
||||
`ArealMap.dll` и `Effect.dll` имеют изменённый `.text` при прежних адресах
|
||||
exports. Compatibility headers фиксируют внешний ABI один раз, но внутренняя
|
||||
таблица адресов, тестов и semantic deltas выбирается по build fingerprint.
|
||||
|
||||
## Файловая поверхность
|
||||
|
||||
### Каталог как внешний API
|
||||
|
||||
@@ -73,6 +73,8 @@ nav:
|
||||
- WEAR и MAT0: reference/materials.md
|
||||
- Texm: reference/texm.md
|
||||
- Render frame: reference/render-frame.md
|
||||
- Атмосфера и небо: reference/atmosphere.md
|
||||
- Эффекты окружения: reference/environment-effects.md
|
||||
- Оригинальные модули: reference/original-binaries.md
|
||||
- Приложения:
|
||||
- Глоссарий: appendices/glossary.md
|
||||
|
||||
Reference in New Issue
Block a user