diff --git a/src/app/tools/tankopedia/[id]/components/Lighting.tsx b/src/app/tools/tankopedia/[id]/components/Lighting.tsx
index 9b6a178f2..fb9d16200 100644
--- a/src/app/tools/tankopedia/[id]/components/Lighting.tsx
+++ b/src/app/tools/tankopedia/[id]/components/Lighting.tsx
@@ -3,13 +3,13 @@ export function Lighting() {
<>
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx
similarity index 58%
rename from src/app/tools/tankopedia/[id]/components/Model/components/TankArmor.tsx
rename to src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx
index 9d22dcbef..b6d3d7003 100644
--- a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor.tsx
+++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ArmorHighlighting.tsx
@@ -1,27 +1,30 @@
import { useLoader } from '@react-three/fiber';
-import { memo, useEffect, useRef } from 'react';
+import { useEffect, useRef } from 'react';
import { Euler, Group, Mesh, Vector3 } from 'three';
import { GLTFLoader } from 'three-stdlib';
import { degToRad } from 'three/src/math/MathUtils';
-import { ArmorMesh } from '../../../../../../../components/ArmorMesh';
-import { HeadsUpDisplay } from '../../../../../../../components/HeadsUpDisplay';
-import { X_AXIS, Y_AXIS, Z_AXIS } from '../../../../../../../constants/axis';
-import { asset } from '../../../../../../../core/blitzkrieg/asset';
+import { ArmorMesh } from '../../../../../../../../../components/ArmorMesh';
+import {
+ X_AXIS,
+ Y_AXIS,
+ Z_AXIS,
+} from '../../../../../../../../../constants/axis';
+import { asset } from '../../../../../../../../../core/blitzkrieg/asset';
import {
ModelTransformEventData,
modelTransformEvent,
-} from '../../../../../../../core/blitzkrieg/modelTransform';
-import { nameToArmorId } from '../../../../../../../core/blitzkrieg/nameToArmorId';
-import { resolveArmor } from '../../../../../../../core/blitzkrieg/resolveThickness';
-import { useModelDefinitions } from '../../../../../../../core/hooks/useModelDefinitions';
-import { useTankopedia } from '../../../../../../../stores/tankopedia';
-import { Duel } from '../../../page';
+} from '../../../../../../../../../core/blitzkrieg/modelTransform';
+import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
+import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
+import { useModelDefinitions } from '../../../../../../../../../core/hooks/useModelDefinitions';
+import { useTankopedia } from '../../../../../../../../../stores/tankopedia';
+import { Duel } from '../../../../../page';
-interface TankArmorProps {
+interface ArmorHighlightingProps {
duel: Duel;
}
-export const TankArmor = memo(({ duel }) => {
+export function ArmorHighlighting({ duel }: ArmorHighlightingProps) {
const wrapper = useRef(null);
const awaitedModelDefinitions = useModelDefinitions();
const showSpacedArmor = useTankopedia(
@@ -136,18 +139,47 @@ export const TankArmor = memo(({ duel }) => {
).applyAxisAngle(X_AXIS, Math.PI / 2);
return (
-
-
+
+ {armorNodes.map((node) => {
+ const isHull = node.name.startsWith('hull_');
+ const isVisible = isHull;
+ const armorId = nameToArmorId(node.name);
+ const { spaced, thickness } = resolveArmor(
+ tankModelDefinition.armor,
+ armorId,
+ );
+
+ if (
+ !isVisible ||
+ thickness === undefined ||
+ (spaced && !showSpacedArmor)
+ )
+ return null;
+
+ return (
+
+ );
+ })}
+
+
{armorNodes.map((node) => {
- const isHull = node.name.startsWith('hull_');
- const isVisible = isHull;
+ const isCurrentTurret = node.name.startsWith(
+ `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
+ );
+ const isVisible = isCurrentTurret;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
- tankModelDefinition.armor,
+ turretModelDefinition.armor,
armorId,
);
@@ -163,43 +195,21 @@ export const TankArmor = memo(({ duel }) => {
duel={duel}
key={node.uuid}
geometry={(node as Mesh).geometry}
+ position={turretOrigin}
isSpaced={spaced ?? false}
thickness={thickness}
/>
);
})}
-
- {modelNodes.map((node) => {
- const isWheel = node.name.startsWith('chassis_wheel_');
- const isTrack = node.name.startsWith('chassis_track_');
- const isVisible = isWheel || isTrack;
- const thickness = 50;
-
- if (!isVisible || !showSpacedArmor) return null;
-
- return (
-
- );
- })}
-
-
+
{armorNodes.map((node) => {
- const isCurrentTurret = node.name.startsWith(
- `turret_${turretModelDefinition.model
- .toString()
- .padStart(2, '0')}`,
+ const isCurrentGun = node.name.startsWith(
+ `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
);
- const isVisible = isCurrentTurret;
+ const isVisible = isCurrentGun;
const armorId = nameToArmorId(node.name);
const { spaced, thickness } = resolveArmor(
- turretModelDefinition.armor,
+ gunModelDefinition.armor,
armorId,
);
@@ -215,66 +225,14 @@ export const TankArmor = memo(({ duel }) => {
duel={duel}
key={node.uuid}
geometry={(node as Mesh).geometry}
- position={turretOrigin}
+ position={turretOrigin.clone().add(gunOrigin)}
isSpaced={spaced ?? false}
thickness={thickness}
/>
);
})}
-
- {armorNodes.map((node) => {
- const isCurrentGun = node.name.startsWith(
- `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
- );
- const isVisible = isCurrentGun;
- const armorId = nameToArmorId(node.name);
- const { spaced, thickness } = resolveArmor(
- gunModelDefinition.armor,
- armorId,
- );
-
- if (
- !isVisible ||
- thickness === undefined ||
- (spaced && !showSpacedArmor)
- )
- return null;
-
- return (
-
- );
- })}
-
- {modelNodes.map((node) => {
- const isCurrentGun =
- node.name ===
- `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
- const isVisible = isCurrentGun;
- const thickness = 50;
-
- if (!isVisible || !showSpacedArmor) return null;
-
- return (
-
- );
- })}
-
-
+
);
-});
+}
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx
new file mode 100644
index 000000000..cc3a187ad
--- /dev/null
+++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/components/ExternalModuleMask.tsx
@@ -0,0 +1,263 @@
+import { useLoader } from '@react-three/fiber';
+import { memo, useEffect, useRef } from 'react';
+import { Euler, Group, Mesh, Vector3 } from 'three';
+import { GLTFLoader } from 'three-stdlib';
+import { degToRad } from 'three/src/math/MathUtils';
+import { ArmorMeshExternalModuleMask } from '../../../../../../../../../components/ArmorMesh';
+import {
+ X_AXIS,
+ Y_AXIS,
+ Z_AXIS,
+} from '../../../../../../../../../constants/axis';
+import { asset } from '../../../../../../../../../core/blitzkrieg/asset';
+import {
+ ModelTransformEventData,
+ modelTransformEvent,
+} from '../../../../../../../../../core/blitzkrieg/modelTransform';
+import { nameToArmorId } from '../../../../../../../../../core/blitzkrieg/nameToArmorId';
+import { resolveArmor } from '../../../../../../../../../core/blitzkrieg/resolveThickness';
+import { useModelDefinitions } from '../../../../../../../../../core/hooks/useModelDefinitions';
+import { useTankopedia } from '../../../../../../../../../stores/tankopedia';
+import { Duel } from '../../../../../page';
+
+interface ExternalModuleMaskProps {
+ duel: Duel;
+}
+
+export const ExternalModuleMask = memo(({ duel }) => {
+ const wrapper = useRef(null);
+ const awaitedModelDefinitions = useModelDefinitions();
+ const showSpacedArmor = useTankopedia(
+ (state) => state.model.visual.showSpacedArmor,
+ );
+ const turretContainer = useRef(null);
+ const gunContainer = useRef(null);
+ const initialTankopediaState = useTankopedia.getState();
+
+ useEffect(() => {
+ if (!awaitedModelDefinitions) return;
+
+ const turretOrigin = new Vector3(
+ tankModelDefinition.turretOrigin[0],
+ tankModelDefinition.turretOrigin[1],
+ -tankModelDefinition.turretOrigin[2],
+ ).applyAxisAngle(X_AXIS, Math.PI / 2);
+ const gunOrigin = new Vector3(
+ turretModelDefinition.gunOrigin[0],
+ turretModelDefinition.gunOrigin[1],
+ -turretModelDefinition.gunOrigin[2],
+ ).applyAxisAngle(X_AXIS, Math.PI / 2);
+ const turretPosition = new Vector3();
+ const turretRotation = new Euler();
+ const gunPosition = new Vector3();
+ const gunRotation = new Euler();
+
+ function handleModelTransform({ yaw, pitch }: ModelTransformEventData) {
+ gunPosition
+ .set(0, 0, 0)
+ .sub(turretOrigin)
+ .sub(gunOrigin)
+ .applyAxisAngle(X_AXIS, pitch)
+ .add(gunOrigin)
+ .add(turretOrigin);
+ gunRotation.set(pitch, 0, 0);
+ gunContainer.current?.position.copy(gunPosition);
+ gunContainer.current?.rotation.copy(gunRotation);
+
+ if (yaw === undefined) return;
+
+ turretPosition
+ .set(0, 0, 0)
+ .sub(turretOrigin)
+ .applyAxisAngle(new Vector3(0, 0, 1), yaw);
+ turretRotation.set(0, 0, yaw);
+
+ if (tankModelDefinition.turretRotation) {
+ const initialPitch = -degToRad(
+ tankModelDefinition.turretRotation.pitch,
+ );
+ const initialYaw = -degToRad(tankModelDefinition.turretRotation.yaw);
+ const initialRoll = -degToRad(tankModelDefinition.turretRotation.roll);
+
+ turretPosition
+ .applyAxisAngle(X_AXIS, initialPitch)
+ .applyAxisAngle(Y_AXIS, initialRoll)
+ .applyAxisAngle(Z_AXIS, initialYaw);
+ turretRotation.x += initialPitch;
+ turretRotation.y += initialRoll;
+ turretRotation.z += initialYaw;
+ }
+
+ turretPosition.add(turretOrigin);
+ turretContainer.current?.position.copy(turretPosition);
+ turretContainer.current?.rotation.copy(turretRotation);
+ }
+
+ modelTransformEvent.on(handleModelTransform);
+
+ return () => {
+ modelTransformEvent.off(handleModelTransform);
+ };
+ }, [awaitedModelDefinitions]);
+
+ useEffect(() => {
+ const unsubscribe = useTankopedia.subscribe(
+ (state) => state.mode,
+ (mode) => {
+ if (wrapper.current) wrapper.current.visible = mode === 'armor';
+ },
+ );
+
+ return unsubscribe;
+ });
+
+ const armorGltf = useLoader(
+ GLTFLoader,
+ asset(`3d/tanks/armor/${duel.protagonist.tank.id}.glb`),
+ );
+ const modelGltf = useLoader(
+ GLTFLoader,
+ asset(`3d/tanks/models/${duel.protagonist.tank.id}.glb`),
+ );
+
+ const armorNodes = Object.values(armorGltf.nodes);
+ const modelNodes = Object.values(modelGltf.nodes);
+ const tankModelDefinition = awaitedModelDefinitions[duel.protagonist.tank.id];
+ const turretModelDefinition =
+ tankModelDefinition.turrets[duel.protagonist.turret.id];
+ const gunModelDefinition =
+ turretModelDefinition.guns[duel.protagonist.gun.id];
+ const turretOrigin = new Vector3(
+ tankModelDefinition.turretOrigin[0],
+ tankModelDefinition.turretOrigin[1],
+ -tankModelDefinition.turretOrigin[2],
+ ).applyAxisAngle(X_AXIS, Math.PI / 2);
+ const gunOrigin = new Vector3(
+ turretModelDefinition.gunOrigin[0],
+ turretModelDefinition.gunOrigin[1],
+ -turretModelDefinition.gunOrigin[2],
+ ).applyAxisAngle(X_AXIS, Math.PI / 2);
+
+ return (
+
+ {armorNodes.map((node) => {
+ const isHull = node.name.startsWith('hull_');
+ const isVisible = isHull;
+ const armorId = nameToArmorId(node.name);
+ const { spaced, thickness } = resolveArmor(
+ tankModelDefinition.armor,
+ armorId,
+ );
+
+ if (
+ !isVisible ||
+ thickness === undefined ||
+ (spaced && !showSpacedArmor)
+ )
+ return null;
+
+ return (
+
+ );
+ })}
+
+ {modelNodes.map((node) => {
+ const isWheel = node.name.startsWith('chassis_wheel_');
+ const isTrack = node.name.startsWith('chassis_track_');
+ const isVisible = isWheel || isTrack;
+
+ if (!isVisible || !showSpacedArmor) return null;
+
+ return (
+
+ );
+ })}
+
+
+ {armorNodes.map((node) => {
+ const isCurrentTurret = node.name.startsWith(
+ `turret_${turretModelDefinition.model.toString().padStart(2, '0')}`,
+ );
+ const isVisible = isCurrentTurret;
+ const armorId = nameToArmorId(node.name);
+ const { spaced, thickness } = resolveArmor(
+ turretModelDefinition.armor,
+ armorId,
+ );
+
+ if (
+ !isVisible ||
+ thickness === undefined ||
+ (spaced && !showSpacedArmor)
+ )
+ return null;
+
+ return (
+
+ );
+ })}
+
+ {armorNodes.map((node) => {
+ const isCurrentGun = node.name.startsWith(
+ `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`,
+ );
+ const isVisible = isCurrentGun;
+ const armorId = nameToArmorId(node.name);
+ const { spaced, thickness } = resolveArmor(
+ gunModelDefinition.armor,
+ armorId,
+ );
+
+ if (
+ !isVisible ||
+ thickness === undefined ||
+ (spaced && !showSpacedArmor)
+ )
+ return null;
+
+ return (
+
+ );
+ })}
+
+ {modelNodes.map((node) => {
+ const isCurrentGun =
+ node.name ===
+ `gun_${gunModelDefinition.model.toString().padStart(2, '0')}`;
+ const isVisible = isCurrentGun;
+
+ if (!isVisible || !showSpacedArmor) return null;
+
+ return (
+
+ );
+ })}
+
+
+
+ );
+});
diff --git a/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx
new file mode 100644
index 000000000..1ed152676
--- /dev/null
+++ b/src/app/tools/tankopedia/[id]/components/Model/components/TankArmor/index.tsx
@@ -0,0 +1,49 @@
+import { createPortal, useFrame } from '@react-three/fiber';
+import { memo } from 'react';
+import { Scene } from 'three';
+import { externalModuleMaskRenderTarget } from '../../../../../../../../components/ArmorMesh';
+import { Duel } from '../../../../page';
+import { ArmorHighlighting } from './components/ArmorHighlighting';
+import { ExternalModuleMask } from './components/ExternalModuleMask';
+
+interface TankArmorProps {
+ duel: Duel;
+}
+
+export const TankArmor = memo(({ duel }) => {
+ const externalModuleMaskScene = new Scene();
+ const armorHighlightingScene = new Scene();
+ const externalModuleMaskPortal = createPortal(
+ ,
+ externalModuleMaskScene,
+ );
+ const armorHighlightingPortal = createPortal(
+ ,
+ armorHighlightingScene,
+ );
+
+ useFrame(({ gl, scene, camera }) => {
+ gl.autoClear = true;
+
+ externalModuleMaskRenderTarget.setSize(
+ gl.domElement.width,
+ gl.domElement.height,
+ );
+ gl.setRenderTarget(externalModuleMaskRenderTarget);
+ gl.render(externalModuleMaskScene, camera);
+
+ gl.autoClear = false;
+
+ gl.setRenderTarget(null);
+ gl.render(scene, camera);
+ gl.clearDepth();
+ gl.render(armorHighlightingScene, camera);
+ }, 1);
+
+ return (
+ <>
+ {externalModuleMaskPortal}
+ {armorHighlightingPortal}
+ >
+ );
+});
diff --git a/src/components/ArmorMesh/components/Armor/index.tsx b/src/components/ArmorMesh/components/Armor/index.tsx
new file mode 100644
index 000000000..a716efe86
--- /dev/null
+++ b/src/components/ArmorMesh/components/Armor/index.tsx
@@ -0,0 +1,96 @@
+import { MeshProps, useFrame } from '@react-three/fiber';
+import { useRef } from 'react';
+import { MeshStandardMaterial, ShaderMaterial, Vector2 } from 'three';
+import ThreeCustomShaderMaterial from 'three-custom-shader-material';
+import { degToRad } from 'three/src/math/MathUtils';
+import { Duel } from '../../../../app/tools/tankopedia/[id]/page';
+import { canSplash } from '../../../../core/blitz/canSplash';
+import { isExplosive } from '../../../../core/blitz/isExplosive';
+import { resolveNearPenetration } from '../../../../core/blitz/resolveNearPenetration';
+import { externalModuleMaskRenderTarget } from '../ExternalModuleMask';
+import fragmentShader from './shaders/fragment.glsl';
+import vertexShader from './shaders/vertex.glsl';
+
+interface ArmorMeshProps extends MeshProps {
+ duel: Duel;
+ isSpaced: boolean;
+ isExternalModule?: boolean;
+ thickness: number;
+}
+
+export function ArmorMesh({
+ isSpaced,
+ isExternalModule = false,
+ thickness,
+ duel,
+ ...props
+}: ArmorMeshProps) {
+ const { shell } = duel.antagonist;
+ const material = useRef(null);
+ const resolution = new Vector2();
+
+ useFrame(({ gl }) => {
+ if (material.current) {
+ material.current.uniforms.externalModuleMask.value =
+ externalModuleMaskRenderTarget.texture;
+ material.current.uniforms.resolution.value = resolution.set(
+ gl.domElement.width,
+ gl.domElement.height,
+ );
+ }
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* */}
+
+ >
+ );
+}
diff --git a/src/components/ArmorMesh/shaders/fragment.glsl b/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl
similarity index 86%
rename from src/components/ArmorMesh/shaders/fragment.glsl
rename to src/components/ArmorMesh/components/Armor/shaders/fragment.glsl
index 22d5cb206..f3946b65b 100644
--- a/src/components/ArmorMesh/shaders/fragment.glsl
+++ b/src/components/ArmorMesh/components/Armor/shaders/fragment.glsl
@@ -8,8 +8,12 @@ uniform float penetration;
uniform float caliber;
uniform float ricochet;
uniform float normalization;
+uniform sampler2D externalModuleMask;
+uniform vec2 resolution;
void main() {
+ vec4 externalModuleMaskColor = texture2D(externalModuleMask, gl_FragCoord.xy / resolution);
+ bool isUnderExternalModule = externalModuleMaskColor.r == 1.0;
vec3 normalizedNormal = normalize(vNormal);
vec3 normalizedViewPosition = normalize(vCSMViewPosition);
float dotProduct = dot(normalizedNormal, -normalizedViewPosition);
@@ -19,7 +23,7 @@ void main() {
float splashChance = -1.0;
bool threeCalibersRule = caliber > 3.0 * thickness;
- if (!isExternalModule && !isExplosive && !threeCalibersRule && angle >= ricochet) {
+ if (!isUnderExternalModule && !isExternalModule && !isExplosive && !threeCalibersRule && angle >= ricochet) {
penetrationChance = 0.0;
splashChance = 0.0;
} else {
diff --git a/src/components/ArmorMesh/shaders/vertex.glsl b/src/components/ArmorMesh/components/Armor/shaders/vertex.glsl
similarity index 100%
rename from src/components/ArmorMesh/shaders/vertex.glsl
rename to src/components/ArmorMesh/components/Armor/shaders/vertex.glsl
diff --git a/src/components/ArmorMesh/components/ExternalModuleMask.tsx b/src/components/ArmorMesh/components/ExternalModuleMask.tsx
new file mode 100644
index 000000000..6c7c0d1f2
--- /dev/null
+++ b/src/components/ArmorMesh/components/ExternalModuleMask.tsx
@@ -0,0 +1,29 @@
+import { MeshProps } from '@react-three/fiber';
+import { WebGLRenderTarget } from 'three';
+
+export const externalModuleMaskRenderTarget = new WebGLRenderTarget();
+
+interface ArmorMeshExternalModuleMaskProps extends MeshProps {
+ exclude?: boolean;
+}
+
+export function ArmorMeshExternalModuleMask({
+ exclude = false,
+ ...props
+}: ArmorMeshExternalModuleMaskProps) {
+ return (
+ <>
+ {exclude && (
+
+
+
+ )}
+
+ {!exclude && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/ArmorMesh/index.tsx b/src/components/ArmorMesh/index.tsx
index 370559173..b64eb57f2 100644
--- a/src/components/ArmorMesh/index.tsx
+++ b/src/components/ArmorMesh/index.tsx
@@ -1,63 +1,2 @@
-import { MeshProps } from '@react-three/fiber';
-import { MeshStandardMaterial } from 'three';
-import CustomShaderMaterial from 'three-custom-shader-material';
-import { degToRad } from 'three/src/math/MathUtils';
-import { Duel } from '../../app/tools/tankopedia/[id]/page';
-import { canSplash } from '../../core/blitz/canSplash';
-import { isExplosive } from '../../core/blitz/isExplosive';
-import { resolveNearPenetration } from '../../core/blitz/resolveNearPenetration';
-import fragmentShader from './shaders/fragment.glsl';
-import vertexShader from './shaders/vertex.glsl';
-
-interface ArmorMeshProps extends MeshProps {
- duel: Duel;
- isSpaced: boolean;
- isExternalModule?: boolean;
- thickness: number;
-}
-
-export function ArmorMesh({
- isSpaced,
- isExternalModule = false,
- thickness,
- duel,
- ...props
-}: ArmorMeshProps) {
- const { shell } = duel.antagonist;
-
- return (
- <>
- {!isSpaced && (
-
-
-
- )}
-
-
-
-
- >
- );
-}
+export * from './components/Armor';
+export * from './components/ExternalModuleMask';