diff --git a/source/cache/cache_files.c b/source/cache/cache_files.c index 4c62ee1b..91c488c6 100644 --- a/source/cache/cache_files.c +++ b/source/cache/cache_files.c @@ -134,6 +134,7 @@ symbols in this file: #include "interface/ui_widget.h" #include "scenario/scenario_definitions.h" #include "sound/sound_manager.h" +#include "models/models.h" #include "tag_schema.h" #include "custom_edition_cache.h" /* port: port/linux/game/custom_edition_cache.c */ #include "cache_file_formats.h" /* port: CUSTOM_EDITION_TAG_CACHE_BYTES */ @@ -1270,6 +1271,9 @@ long scenario_tags_load( pal_tags_loaded(cache_file_globals.header.build); } + /* The two powerups' authored render spheres can be smaller than + * their rigid meshes. Derive their bounds while tags are writable. */ + models_fix_powerup_render_bounds(); /* port: the menus' tags, added to the map's (port/linux/game/menu_tags.c) */ { extern void menu_tags_loaded(char const *map_name); diff --git a/source/models/models.c b/source/models/models.c index f087bf87..3e7ba1e5 100644 --- a/source/models/models.c +++ b/source/models/models.c @@ -83,6 +83,10 @@ symbols in this file: #include "shaders/shaders.h" #include "rasterizer/rasterizer_console_vars.h" #include "rasterizer/rasterizer_model_types.h" +#include "cache/cache_files.h" +#include "items/equipment_definitions.h" +#include +#include /* ---------- constants */ @@ -458,6 +462,82 @@ static void render_model_parts( /* ---------- public code */ +static boolean model_rigid_render_radius( + struct model const *model, + real_point3d const *center, + real *radius) +{ + struct model_node const *root; + real maximum_squared = 0.0f; + long geometry_index, vertex_count = 0; + if (model->nodes.count != 1 || !model->nodes.address || + model->geometries.count <= 0 || model->geometries.count > MAXIMUM_GEOMETRIES_PER_MODEL || + !model->geometries.address) return FALSE; + root = TAG_BLOCK_GET_ELEMENT(&model->nodes, 0, struct model_node); + /* Include every LOD/permutation. A one-node model has a fixed mesh in + * root-node space; skeletal animation needs a different bounds policy. */ + for (geometry_index = 0; geometry_index < model->geometries.count; ++geometry_index) + { + struct model_geometry const *geometry = TAG_BLOCK_GET_ELEMENT(&model->geometries, geometry_index, struct model_geometry); + long part_index; + if (geometry->parts.count < 0 || geometry->parts.count > MAXIMUM_PARTS_PER_MODEL_GEOMETRY || + (geometry->parts.count && !geometry->parts.address)) return FALSE; + for (part_index = 0; part_index < geometry->parts.count; ++part_index) + { + struct model_geometry_part const *part = TAG_BLOCK_GET_ELEMENT(&geometry->parts, part_index, struct model_geometry_part); + struct vertex_buffer const *buffer = &part->vertex_buffer; + byte *vertices = NULL; + long stride, i; + boolean valid = TRUE; + if (TEST_FLAG(part->flags, _model_geometry_part_stripped_bit)) continue; + if (TEST_FLAG(part->flags, _model_geometry_part_local_nodes_bit) || !buffer->hardware_format || buffer->offset || + buffer->count <= 0 || buffer->count > MAXIMUM_VERTICES_PER_MODEL_GEOMETRY_PART || + (buffer->type != _rasterizer_vertex_type_model_compressed && buffer->type != _rasterizer_vertex_type_model_uncompressed)) return FALSE; + stride = rasterizer_geometry_get_vertex_size(buffer->type); + IDirect3DVertexBuffer8_Lock(buffer->hardware_format, 0, 0, &vertices, D3DLOCK_READONLY); + if (!vertices) valid = FALSE; + for (i = 0; valid && i < buffer->count; ++i) + { + real_point3d point; + real squared; + matrix4x3_transform_point(&root->runtime_default_inverse_matrix, + (real_point3d const *)(vertices + i * stride), &point); + squared = distance_squared3d(&point, center); + if (!(squared >= 0.0f && squared < FLT_MAX)) valid = FALSE; + else maximum_squared = MAX(maximum_squared, squared); + } + IDirect3DVertexBuffer8_Unlock(buffer->hardware_format); + if (!valid) return FALSE; + vertex_count += buffer->count; + } + } + if (!vertex_count) return FALSE; + /* Round outward so the extremal vertex stays inside the sphere. */ + *radius = nextafterf(sqrtf(maximum_squared), FLT_MAX); + return TRUE; +} + +void models_fix_powerup_render_bounds(void) +{ + struct tag_iterator iterator; + long index; + tag_iterator_new(&iterator, EQUIPMENT_DEFINITION_TAG); + while ((index = tag_iterator_next(&iterator)) != NONE) + { + struct equipment_definition *equipment = equipment_definition_get(index); + real radius; + if ((equipment->equipment.powerup_type == _equipment_powerup_overshield || + equipment->equipment.powerup_type == _equipment_powerup_active_camouflage) && + equipment->object.model.index != NONE && equipment->object.animation_graph.index == NONE && + model_rigid_render_radius(model_definition_get(equipment->object.model.index), &equipment->object.bounding_offset, &radius) && + radius > equipment->object.render_bounding_radius) + { + /* Derived tag data only: leave physics, pickup radius and saves intact. */ + equipment->object.render_bounding_radius = radius; + } + } +} + void model_interpolate_node_orientations( struct model const *model, struct real_orientation *original_node_orientations, diff --git a/source/models/models.h b/source/models/models.h index 44c49396..d8445784 100644 --- a/source/models/models.h +++ b/source/models/models.h @@ -27,6 +27,8 @@ union real_vector3d; /* ---------- prototypes/MODELS.C */ +void models_fix_powerup_render_bounds(void); + void model_interpolate_node_orientations( struct model const *model, struct real_orientation *original_node_orientations, diff --git a/source/objects/objects.h b/source/objects/objects.h index 20cf85ff..00e76414 100644 --- a/source/objects/objects.h +++ b/source/objects/objects.h @@ -529,6 +529,10 @@ __inline void object_get_render_bounding_sphere( *center = object->object.bounding_sphere_center; *radius = object_definition_get(object->definition_index)->object.render_bounding_radius; + /* Growing an object must grow its culling sphere. Preserve the existing + * conservative sphere for scales at or below one. */ + if (object->object.scale > 1.0f) + *radius *= object->object.scale; return; } diff --git a/tools/test_powerup_bounds.py b/tools/test_powerup_bounds.py new file mode 100644 index 00000000..616b1d42 --- /dev/null +++ b/tools/test_powerup_bounds.py @@ -0,0 +1,248 @@ +"""Test production powerup bounds and frustum culling, without a game/profile. + +--map additionally measures both actual Xbox powerup meshes. Assets stay local. +""" +import argparse +import re +import os +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import zlib +ROOT=Path(__file__).resolve().parent.parent + +def block(source, marker): + start = source.index(marker) + end = source.index('{', start) + 1 + depth = 1 + while depth: + depth += (source[end] == '{') - (source[end] == '}') + end += 1 + return source[start:end] + +PRELUDE=r''' +#define _CRT_SECURE_NO_WARNINGS +#include +#include +#include +#include +#include +#include +typedef unsigned char byte,boolean; +typedef unsigned short word; +typedef float real; +typedef union {struct {real x,y,z;};struct {real i,j,k;};real n[3];} real_point3d,real_vector3d; +typedef struct {real i,j;} real_vector2d; +typedef struct {real red,green,blue;} real_rgb_color; +typedef struct {real i,j,k,w;} real_quaternion; +typedef struct real_matrix4x3 {real scale;real_vector3d forward,left,up;real_point3d position;} real_matrix4x3; +typedef struct {real_vector3d n;real d;} real_plane3d; +typedef struct {real x0,x1,y0,y1,z0,z1;} real_rectangle3d; +struct render_frustum {real_rectangle3d world_bounds;real_plane3d world_planes[6];}; +struct tag_block {long count;void *address,*definition;}; +struct tag_reference {unsigned long group_tag;char *name;long name_length,index;}; +struct tag_iterator {long absolute_index;}; +#define TRUE 1 +#define FALSE 0 +#define NONE -1 +#define FLAG(b) (1u<<(b)) +#define TEST_FLAG(f,b) (((f)&FLAG(b))!=0) +#define MAX(a,b) ((a)>(b)?(a):(b)) +#define TAG_STRING_LENGTH 31 +#define UNSIGNED_SHORT_MAX 65535 +#define TAG_BLOCK_GET_ELEMENT(b,i,t) ((t*)((byte*)(b)->address+(i)*sizeof(t))) +#define D3DLOCK_READONLY 128 +#define CHECK(c) do{if(!(c)){fprintf(stderr,"FAIL %d: %s\n",__LINE__,#c);exit(1);}}while(0) +#define match_assert(file,line,c) CHECK(c) +#define OBJECTS_H_FILE "test" +struct test_buffer {byte *data;}; +static void IDirect3DVertexBuffer8_Lock(void *b,int offset,int size,byte **data,int flags){*data=b&&((struct test_buffer*)b)->data?((struct test_buffer*)b)->data+offset:NULL;} +static void IDirect3DVertexBuffer8_Unlock(void *b){} +static long rasterizer_geometry_get_vertex_size(short type){return type==5?32:68;} +''' +HARNESS=r''' +static struct model models[8]; +static struct equipment_definition equipment[8]; +static long equipment_count; +#define equipment_definition_get(i) (equipment+(i)) +#define object_definition_get(i) ((struct object_definition*)(equipment+(i))) +#define model_definition_get(i) (models+(i)) +static void tag_iterator_new(struct tag_iterator *i,long group){i->absolute_index=0;} +static long tag_iterator_next(struct tag_iterator *i){return i->absolute_indexabsolute_index++:NONE;} +struct object_datum {long definition_index;struct {real_point3d bounding_sphere_center;real scale;} object;}; +static struct object_datum object; +static struct object_datum *object_get(long i){return &object;} +''' +TESTS=r''' +static struct model_node node; +static struct model_geometry geometries[2]; +static struct model_geometry_part parts[2]; +static float vertices[2][3][17]; +static struct test_buffer buffers[2]; +static void initialize(void){ + memset(equipment,0,sizeof(equipment));memset(models,0,sizeof(models));memset(&node,0,sizeof(node)); + memset(geometries,0,sizeof(geometries));memset(parts,0,sizeof(parts));memset(vertices,0,sizeof(vertices)); + node.runtime_default_inverse_matrix=(real_matrix4x3){.scale=1,.forward={.x=1},.left={.y=1},.up={.z=1},.position={.z=-.1f}}; + for(int i=0;i<2;i++){ + vertices[i][0][0]=.15f+i*.05f;vertices[i][0][2]=.15f;vertices[i][1][1]=-.15f;vertices[i][2][2]=.3f+i*.1f; + buffers[i].data=(byte*)vertices[i];parts[i].vertex_buffer=(struct vertex_buffer){4,0,3,0,NULL,buffers+i}; + geometries[i].parts=(struct tag_block){1,parts+i,NULL}; + } + models[0].nodes=(struct tag_block){1,&node,NULL};models[0].geometries=(struct tag_block){2,geometries,NULL}; + equipment_count=3; + for(int i=0;i<3;i++){ + equipment[i].object.bounding_radius=.1f;equipment[i].object.render_bounding_radius=i==2?10:.1f; + equipment[i].object.animation_graph.index=NONE;equipment[i].object.model.index=0; + equipment[i].equipment.powerup_type=i==1?_equipment_powerup_health:_equipment_powerup_overshield; + } +} +static void tests(void){ + initialize();struct equipment_definition before[3];memcpy(before,equipment,sizeof(before)); + models_fix_powerup_render_bounds();CHECK(equipment[0].object.render_bounding_radius>.3f); + before[0].object.render_bounding_radius=equipment[0].object.render_bounding_radius;CHECK(!memcmp(before,equipment,sizeof(before))); + real grown=equipment[0].object.render_bounding_radius;models_fix_powerup_render_bounds();CHECK(grown==equipment[0].object.render_bounding_radius); + /* Atomic fallback: a valid first LOD must not mask a bad later LOD. */ + for(int c=0;c<14;c++){ + initialize();switch(c){case 0:models[0].nodes.count=2;break;case 1:parts[1].vertex_buffer.type=0;break; + case 2:parts[1].vertex_buffer.hardware_format=NULL;break;case 3:parts[1].vertex_buffer.count=0;break; + case 4:parts[1].flags=2;break;case 5:vertices[1][0][0]=NAN;break;case 6:models[0].geometries.address=NULL;break; + case 7:equipment[0].object.model.index=NONE;equipment[2].object.model.index=NONE;break; + case 8:equipment[0].object.animation_graph.index=5;equipment[2].object.animation_graph.index=5;break; + case 9:parts[1].vertex_buffer.offset=1;break;case 10:buffers[1].data=NULL;break;case 11:models[0].nodes.address=NULL;break;case 12:node.runtime_default_inverse_matrix.scale=NAN;break;case 13:models[0].geometries.count=257;break;} + memcpy(before,equipment,sizeof(before));models_fix_powerup_render_bounds();CHECK(!memcmp(before,equipment,sizeof(before))); + } + initialize();equipment[0].object.bounding_offset=(real_point3d){.x=.3f,.z=.1f}; + real radius;CHECK(model_rigid_render_radius(models,&equipment[0].object.bounding_offset,&radius));CHECK(radius>.39f); + parts[1].flags=1;CHECK(model_rigid_render_radius(models,&equipment[0].object.bounding_offset,&radius)); + /* compressed and uncompressed vertices have identical positions */ + float packed[3][8]={0};for(int i=0;i<3;i++)memcpy(packed[i],vertices[0][i],12); + parts[0].vertex_buffer.type=5;buffers[0].data=(byte*)packed; + CHECK(model_rigid_render_radius(models,&equipment[0].object.bounding_offset,&radius)); + object.definition_index=0;object.object.bounding_sphere_center=(real_point3d){.x=3,.y=4,.z=5}; + float scales[]={0,.1f,.5f,1,2,4}; + for(int i=0;i<6;i++){real_point3d center;object.object.scale=scales[i];object_get_render_bounding_sphere(0,¢er,&radius); + CHECK(!memcmp(¢er,&object.object.bounding_sphere_center,sizeof(center))); + CHECK(radius==equipment[0].object.render_bounding_radius*(scales[i]>1?scales[i]:1));} + puts("PASS: all LODs, root transform/offset, both vertex formats, conservative/idempotent growth, 14 atomic fallback paths, scaled culling; only render radius changes"); +} +static void frustum(struct render_frustum *f,float fov,float aspect){ + float tv=tanf(fov*.5f),th=tv*aspect,h=1/sqrtf(1+th*th),v=1/sqrtf(1+tv*tv); + f->world_bounds=(real_rectangle3d){-100*th,100*th,-100*tv,100*tv,-100,0}; + f->world_planes[0]=(real_plane3d){{.x=-h,.z=th*h},0};f->world_planes[1]=(real_plane3d){{.x=h,.z=th*h},0}; + f->world_planes[2]=(real_plane3d){{.y=-v,.z=tv*v},0};f->world_planes[3]=(real_plane3d){{.y=v,.z=tv*v},0}; + f->world_planes[4]=(real_plane3d){{.z=1},-.05f};f->world_planes[5]=(real_plane3d){{.z=-1},100}; +} +static void sweeps(struct model *model,struct equipment_definition *definition){ + real local_radius=definition->object.render_bounding_radius;int checks=0,legacy_failures=0,culled=0; + for(int size=0;size<4;size++)for(int fov=0;fov<3;fov++)for(int viewport=0;viewport<4;viewport++)for(int rotation=0;rotation<4;rotation++)for(int edge=0;edge<4;edge++)for(int step=-20;step<=20;step++){ + float scales[]={.25f,1,2,4},fovs[]={.6f,1.2f,1.8f},aspects[]={16.f/9,4.f/3,32.f/9,8.f/9}; + float scale=scales[size],depth=3,angle=rotation*.6f,tv=tanf(fovs[fov]*.5f),th=tv*aspects[viewport]; + real_matrix4x3 world={.scale=scale,.forward={.x=cosf(angle),.z=sinf(angle)},.left={.y=1},.up={.x=-sinf(angle),.z=cosf(angle)},.position={.z=-depth}}; + if(edge<2)world.position.x=(edge?1:-1)*(depth*th+step*.1f*local_radius*scale*sqrtf(1+th*th)); + else world.position.y=(edge==3?1:-1)*(depth*tv+step*.1f*local_radius*scale*sqrtf(1+tv*tv)); + struct render_frustum f;frustum(&f,fovs[fov],aspects[viewport]); + real_point3d center;matrix4x3_transform_point(&world,&definition->object.bounding_offset,¢er); + object.definition_index=0;equipment[0]=*definition;object.object.scale=scale;object.object.bounding_sphere_center=center; + real radius;real_point3d queried;object_get_render_bounding_sphere(0,&queried,&radius); + int visible=render_frustum_sphere_visible(&f,&queried,radius),has_vertex=0; + for(int g=0;ggeometries.count;g++){ + struct model_geometry *geo=TAG_BLOCK_GET_ELEMENT(&model->geometries,g,struct model_geometry); + for(int p=0;pparts.count;p++){ + struct vertex_buffer *buffer=&TAG_BLOCK_GET_ELEMENT(&geo->parts,p,struct model_geometry_part)->vertex_buffer; + struct test_buffer *b=buffer->hardware_format; + for(int i=0;icount;i++){ + real_point3d root,point;matrix4x3_transform_point(&((struct model_node*)model->nodes.address)->runtime_default_inverse_matrix,(real_point3d*)(b->data+i*rasterizer_geometry_get_vertex_size(buffer->type)),&root); + matrix4x3_transform_point(&world,&root,&point);CHECK(distance_squared3d(&point,¢er)<=radius*radius*1.00001f); + int inside=1;for(int plane=0;plane<6;plane++)inside&=plane3d_distance_to_point(&f.world_planes[plane],&point)<-.000001f; + has_vertex|=inside; + } + } + } + if(has_vertex){CHECK(visible);legacy_failures+=!render_frustum_sphere_visible(&f,¢er,.1f);} + culled+=!visible;checks++; + } + CHECK(legacy_failures>0&&culled>0);printf("PASS: %d edge sweeps; %d premature legacy culls; mesh containment, four viewport ratios, rotations, FOVs and scales; fully offscreen culling retained\n",checks,legacy_failures); +} +static void asset_test(const char *path){ + FILE *f=fopen(path,"rb");CHECK(f);unsigned int count;CHECK(fread(&count,4,1,f)==1&&count==2); + for(int entry=0;entry.1f); + before.object.render_bounding_radius=equipment[0].object.render_bounding_radius;CHECK(!memcmp(&before,equipment,sizeof(before))); + definition.object.render_bounding_radius=equipment[0].object.render_bounding_radius; + printf("Actual powerup type %d: radius %.6f -> %.6f\n",definition.equipment.powerup_type,.1f,radius);sweeps(&model,&definition); + for(int i=0;i1)asset_test(argv[1]);return 0;} +''' + +def fixture(path): + raw=path.read_bytes();data=raw if struct.unpack_from('