11class_name VMFMaterialConversionContextMenu extends EditorContextMenuPlugin
22
3+ const VMT_TEMPLATE := ("\" LightmappedGeneric\" {\n " \
4+ + "\t $basetexture \" %s \" \n " \
5+ + "}" );
6+
7+ const VMT_BLEND_TEMPLATE := ("\" WorldVertexTransition\" {\n " \
8+ + "\t $basetexture \" %s \" \n " \
9+ + "\t $bumpmap \" %s \" \n " \
10+ + "\t $roughnesstexture \" %s \" \n " \
11+ + "\t $ambientocclusiontexture \" %s \" \n " \
12+
13+ + "\t $basetexture2 \" %s \" \n " \
14+ + "\t $bumpmap2 \" %s \" \n " \
15+ + "\t $roughnesstexture2 \" %s \" \n " \
16+ + "\t $ambientocclusiontexture2 \" %s \" \n " \
17+ + "}" );
18+
319func is_resource (p : String ) -> bool :
420 return p .ends_with (".tres" );
521
22+ func is_texture (p : String ) -> bool :
23+ return ['png' , 'jpg' , 'tga' ].has (p .get_extension ());
24+
25+ func is_vmt (p : String ) -> bool :
26+ return p .get_extension () == 'vmt' ;
27+
628func _popup_menu (paths : PackedStringArray ):
729 var has_resources = Array (paths ).filter (is_resource ).size () > 0 ;
8- if not has_resources : return ;
30+ if has_resources : add_context_menu_item ("Convert to VMT" , convert_resource_to_vmt );
31+
32+ var has_textures = Array (paths ).filter (is_texture ).size () > 0 ;
33+ if has_textures : add_context_menu_item ("Create VMT materials" , create_vmts_from_textures );
34+
35+ var able_to_blend = Array (paths ).filter (is_vmt ).size () > 1 ;
36+ if able_to_blend : add_context_menu_item ("Create VMT Blend Material" , create_blend_material );
37+
38+ func create_blend_material (paths : PackedStringArray ):
39+ var vmts = Array (paths ).filter (is_vmt );
40+ var vmt1 = ResourceLoader .load (vmts [0 ]);
41+ var vmt2 = ResourceLoader .load (vmts [1 ]);
42+
43+ var blend_file_name = vmts [0 ].get_basename ().get_file () + '_' + vmts [1 ].get_basename ().get_file () + '_blend.vmt' ;
44+ var path1 = (vmts [0 ] as String ).get_base_dir ()
45+ var path2 = (vmts [1 ] as String ).get_base_dir ()
46+
47+ var save_path = path1 .get_base_dir () + '/' + blend_file_name ;
48+ print ("Saving blended VMT to: " + save_path );
49+
50+ var file := FileAccess .open (save_path , FileAccess .WRITE );
51+
52+ var details_1 = vmt1 .get_meta ("details" , {});
53+ var details_2 = vmt2 .get_meta ("details" , {});
54+
55+ var basetexture = details_1 .get ("$basetexture" , "" );
56+ var bumpmap = details_1 .get ("$bumpmap" , "" );
57+ var roughnesstexture = details_1 .get ("$roughnesstexture" , "" );
58+ var ambientocclusiontexture = details_1 .get ("$ambientocclusiontexture" , "" );
59+ var basetexture2 = details_2 .get ("$basetexture" , "" );
60+ var bumpmap2 = details_2 .get ("$bumpmap" , "" );
61+ var roughnesstexture2 = details_2 .get ("$roughnesstexture" ,"" );
62+ var ambientocclusiontexture2 = details_2 .get ("$ambientocclusiontexture" , "" );
63+
64+ # file.store_string(VMT_BLEND_TEMPLATE % [basetexture, basetexture2]);
65+ file .store_string (VMT_BLEND_TEMPLATE % [
66+ basetexture ,
67+ bumpmap ,
68+ roughnesstexture ,
69+ ambientocclusiontexture ,
70+ basetexture2 ,
71+ bumpmap2 ,
72+ roughnesstexture2 ,
73+ ambientocclusiontexture2
74+ ]);
75+ file .close ();
76+ EditorInterface .get_resource_filesystem ().scan ();
977
10- add_context_menu_item ("Convert to VMT" , convert_resource_to_vmt );
78+ func create_vmts_from_textures (paths : PackedStringArray ):
79+ var only_textures := Array (paths ).filter (func (path : String ):
80+ return ['png' , 'jpg' , 'tga' ].has (path .get_extension ())
81+ );
82+
83+ for path in only_textures :
84+ var texture : Texture = ResourceLoader .load (path );
85+ var basetexture := path .replace (VMFConfig .materials .target_folder , '' ).substr (1 ).replace ('.' + path .get_extension (), '' ) as String ;
86+ var vmt_path := VMFUtils .normalize_path (VMFConfig .materials .target_folder + '/' + basetexture + '.vmt' );
87+
88+ var file := FileAccess .open (vmt_path , FileAccess .WRITE );
89+ file .store_string (VMT_TEMPLATE % basetexture );
90+ file .close ();
91+
92+ var bytes = generate_vtf_file (texture );
93+ var vtf_file = FileAccess .open (vmt_path .replace ('.vmt' , '.vtf' ), FileAccess .WRITE );
94+ vtf_file .store_buffer (bytes );
95+ vtf_file .close ();
96+
97+ EditorInterface .get_resource_filesystem ().scan ();
1198
1299func convert_resource_to_vmt (paths : PackedStringArray ):
13100 var resources := Array (paths ).filter (is_resource );
@@ -20,7 +107,7 @@ func convert_resource_to_vmt(paths: PackedStringArray):
20107 continue ;
21108
22109 create_vmt_file (resource );
23-
110+
24111 EditorInterface .get_resource_filesystem ().scan ();
25112
26113func create_vmt_file (material : BaseMaterial3D ):
@@ -37,12 +124,8 @@ func create_vmt_file(material: BaseMaterial3D):
37124 if base_texture_path .begins_with ("/" ):
38125 base_texture_path = base_texture_path .substr (1 , base_texture_path .length ());
39126
40- var vmt_string := ("\" LightmappedGeneric\" {\n " \
41- + "\t\" $basetexture\" \" %s \" \n " \
42- + "}" ) % base_texture_path ;
43-
44127 var file := FileAccess .open (vmt_path , FileAccess .WRITE );
45- file .store_string (vmt_string );
128+ file .store_string (VMT_TEMPLATE % base_texture_path );
46129 file .close ();
47130
48131 if ResourceLoader .exists (vtf_path ):
@@ -92,26 +175,36 @@ func generate_vtf_file(texture: Texture2D) -> PackedByteArray:
92175 var aspect_ratio := float (image .get_width ()) / float (image .get_height ());
93176 var bytes := PackedByteArray ();
94177
95- var is_dxt : bool = image .get_format () == Image .FORMAT_DXT1 or image .get_format () == Image .FORMAT_DXT5 or image .get_format () == Image .FORMAT_DXT3 ;
178+ var is_dxt : bool = image .get_format () == Image .FORMAT_DXT1 \
179+ or image .get_format () == Image .FORMAT_DXT5 \
180+ or image .get_format () == Image .FORMAT_DXT3 ;
181+
182+ if not image .is_compressed ():
183+ VMFLogger .warn ("The albedo texture is not VRAM Compressed: %s " % texture .resource_path );
96184
97185 if not is_dxt :
98186 image .decompress ();
99187 image .compress (Image .COMPRESS_S3TC );
100188 image .convert (Image .FORMAT_DXT5 );
101189
102- image .clear_mipmaps ();
103-
104190 bytes += "VTF" .to_utf8_buffer (); # signature
105191 bytes .append (0 );
106192
107193 bytes += int_to_int32 (7 ); # version major
108- bytes += int_to_int32 (1 ); # version minor
109- bytes += int_to_int32 (64 ); # header size for version 7.1
194+ bytes += int_to_int32 (4 ); # version minor (7.4 is standard for Source)
195+ bytes += int_to_int32 (80 ); # header size for version 7.4
110196
111197 bytes += int_to_short (image .get_width ()); # width
112198 bytes += int_to_short (image .get_height ()); # height
113199
114- bytes += int_to_int32 (VTFLoader .Flags .TEXTUREFLAGS_NOMIP | VTFLoader .Flags .TEXTUREFLAGS_SRGB );
200+ var flags : int = VTFLoader .Flags .TEXTUREFLAGS_SRGB | VTFLoader .Flags .TEXTUREFLAGS_NOMIP ;
201+ var mip_levels := 1 ;
202+
203+ if image .has_mipmaps ():
204+ flags &= ~ VTFLoader .Flags .TEXTUREFLAGS_NOMIP ;
205+ mip_levels = image .get_mipmap_count () + 1 ;
206+
207+ bytes += int_to_int32 (flags );
115208 bytes += int_to_short (1 ); # frames
116209 bytes += int_to_short (0 ); # first frame
117210 bytes += PackedByteArray ([0 , 0 , 0 , 0 ]); # padding
@@ -128,12 +221,15 @@ func generate_vtf_file(texture: Texture2D) -> PackedByteArray:
128221 else 15 ;
129222
130223 bytes += int_to_int32 (format ); # high res image format
131- bytes += int_to_byte (1 ) # mipmap count
224+ bytes += int_to_byte (mip_levels ) # mipmap count
132225 bytes += int_to_int32 (13 ) # low res image format
133226
134227 var lowres_image := image .duplicate () as Image ;
135228 var lowres_width := 16 ;
136- var lowres_height := int (16 / aspect_ratio );
229+ var lowres_height := max (1 , int (16 / aspect_ratio ));
230+
231+ # Ensure lowres dimensions are valid
232+ lowres_height = max (1 , min (16 , lowres_height ));
137233
138234 lowres_image .decompress ();
139235 lowres_image .resize (lowres_width , lowres_height , Image .INTERPOLATE_BILINEAR );
@@ -144,9 +240,48 @@ func generate_vtf_file(texture: Texture2D) -> PackedByteArray:
144240
145241 bytes += int_to_byte (lowres_width ); # low res image width
146242 bytes += int_to_byte (lowres_height ); # low res image height
147- bytes += PackedByteArray ([0 ]); # padding
148-
149- bytes += lowres_image .get_data (); # low res image data
150- bytes += image .get_data ()
243+ bytes += int_to_byte (1 ); # depth
244+
245+ # Padding 2 (3 bytes)
246+ bytes += PackedByteArray ([0 , 0 , 0 ]);
247+
248+ # Resource Count (4 bytes)
249+ bytes += int_to_int32 (0 );
250+
251+ # Pad to 80 bytes header
252+ while bytes .size () < 80 :
253+ bytes .append (0 );
254+
255+ # Ensure we only write the first mip level of the lowres image
256+ var lowres_data = lowres_image .get_data ();
257+ var lowres_size = int (lowres_width * lowres_height * 0.5 ); # DXT1 is 4 bits/pixel (0.5 bytes)
258+ if lowres_data .size () > lowres_size :
259+ lowres_data = lowres_data .slice (0 , lowres_size );
260+
261+ bytes += lowres_data ; # low res image data
262+
263+ # VTF stores mipmaps smallest to largest (loader reads from end backwards)
264+ # Godot stores mipmaps largest to smallest
265+ # We need to reverse the order
266+ var image_data = image .get_data ();
267+ var mip_data = []
268+ var offset := 0 ;
269+
270+ # First, extract each mipmap from Godot's data (largest to smallest)
271+ for mip_level in range (mip_levels ):
272+ var block_size := 16 if format in [14 , 15 ] else 8 ;
273+ var mip_width := max (1 , image .get_width () >> mip_level );
274+ var mip_height := max (1 , image .get_height () >> mip_level );
275+ var num_blocks_x := max (1 , (mip_width + 3 ) / 4 );
276+ var num_blocks_y := max (1 , (mip_height + 3 ) / 4 );
277+ var mip_size := num_blocks_x * num_blocks_y * block_size as int ;
278+
279+ mip_data .append (image_data .slice (offset , offset + mip_size ));
280+ offset += mip_size ;
281+
282+ # Now write them in reverse order (smallest to largest for VTF)
283+ mip_data .reverse ();
284+ for data in mip_data :
285+ bytes += data ;
151286
152287 return bytes ;
0 commit comments