chenlei недель назад: 2
Родитель
Сommit
d7a4a550df

+ 48 - 2
README.md

@@ -1,5 +1,51 @@
 # 高新区数字经济产业可视化平台
 
-## 测试环境地址
+基于 React + Vite + Cesium 的园区数字孪生前端。
 
-/Default/腾讯云/项目节点/腾讯云-四维时代-项目测试服务器-111.230.233.212/data/data/museum_guangdong_zhuhai_gaoxin_jingji_video_data/front
+## 运行
+
+需 Node.js 18+。
+
+```bash
+npm install
+npm run dev      # 开发,默认 http://localhost:9020
+npm run build    # 生产构建(public/b3dm 不会打进 dist)
+npm run preview  # 预览构建产物
+```
+
+环境变量写在 `.env` / `.env.production`,常用项:
+
+| 变量 | 说明 |
+| --- | --- |
+| `VITE_API_BASE_URL` | 后端接口地址(开发时 `/api` 会代理到该地址) |
+| `VITE_TIANDITU_TOKEN` | 天地图 Key |
+| `VITE_MAP_IMAGERY_SOURCE` | 底图:`tianditu` 或 `internal` |
+
+3D Tiles 放在 `public/b3dm/`(已 gitignore)。
+
+## 目录结构
+
+```
+.
+├── public/                 静态资源(b3dm 瓦片、解码库、预览视频)
+├── src/
+│   ├── apis/               业务接口
+│   ├── assets/             全局样式
+│   ├── components/         UI 与 Cesium 图层组件
+│   ├── context/            Cesium Viewer 上下文
+│   ├── core/               3D 场景容器
+│   ├── hook/               场景 / 状态 hooks
+│   ├── loader/             瓦片、视频投影加载
+│   ├── sdk/                视频投影、拾取 SDK
+│   ├── shared/             工具函数
+│   ├── store/              Redux
+│   ├── storeDB/            数据请求层
+│   ├── views/              页面(首页、园区、企业、监控)
+│   ├── App.tsx
+│   ├── main.tsx
+│   ├── configure.ts        请求拦截
+│   └── constant.ts         常量与环境配置
+├── index.html
+├── vite.config.ts
+└── package.json
+```

+ 11 - 1
src/components/VideoFrustumEditor/index.module.scss

@@ -4,7 +4,7 @@
   flex-direction: column;
   gap: 4px;
   padding: 12px;
-  background: rgba(0, 0, 0, 0.92);
+  background: rgba(0, 0, 0, 1);
   border: 1px solid rgba(34, 255, 255, 0.35);
   border-radius: 8px;
   color: #fff;
@@ -13,6 +13,16 @@
   max-height: calc(100vh - 72px);
   overflow-y: auto;
   box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
+
+  :global {
+    .ant-btn {
+      --ant-color-text-disabled: #fff;
+    }
+    .ant-checkbox-label,
+    .ant-collapse-panel {
+      color: #fff;
+    }
+  }
 }
 
 .videoPreview {

+ 89 - 1
src/components/VideoFrustumEditor/index.tsx

@@ -5,11 +5,16 @@ import { DEFAULT_VIDEO_PICK_SIZE, DEFAULT_VIDEO_PREVIEW_URL } from "@/constant";
 import {
   ecefToLonLatHeight,
   lonLatHeightToEcef,
+  resolveHorizonCut,
   syncCropRectFromPolygon,
   type QuadCornersUv,
   type VideoProjectorParams,
 } from "@/shared/videoProjector";
 import {
+  computeHorizonV,
+  toVerticalFovDeg,
+} from "@/shared/videoHorizon";
+import {
   CROP_POLYGON_MAX,
   type CropPolygonUv,
 } from "@/sdk/cesiumVideoProjection";
@@ -364,6 +369,8 @@ export const VideoFrustumEditor = ({
   );
 
   const cropPoly = panel.cropPolygon;
+  const horizonV = computeHorizonV(panel.elevation, panel.fov, panel.aspect);
+  const effectiveHorizonCut = resolveHorizonCut(panel);
 
   return (
     <CesiumFixedPanel style={{ top: 56, left: 16, right: "auto" }}>
@@ -388,7 +395,7 @@ export const VideoFrustumEditor = ({
 
         <Collapse
           className={style.collapse}
-          defaultActiveKey={["proj", "cam", "crop", "quad"]}
+          defaultActiveKey={["proj", "level", "cam", "crop", "quad"]}
           bordered={false}
           items={[
             {
@@ -509,6 +516,87 @@ export const VideoFrustumEditor = ({
               ),
             },
             {
+              key: "level",
+              label: "平视增强",
+              children: (
+                <>
+                  <div className={style.hint}>
+                    垂直视场角 {toVerticalFovDeg(panel.fov, panel.aspect).toFixed(1)}
+                    °,地平线位于{" "}
+                    {Number.isFinite(horizonV) && horizonV < 1
+                      ? `画面 ${(horizonV * 100).toFixed(0)}% 高度`
+                      : "画面之外(无需裁切)"}
+                  </div>
+                  <div className={style.checkRow}>
+                    <Checkbox
+                      checked={panel.horizonAuto}
+                      onChange={(e) =>
+                        patchPanel({ horizonAuto: e.target.checked })
+                      }
+                    >
+                      自动裁切地平线以上(当前 {effectiveHorizonCut.toFixed(3)})
+                    </Checkbox>
+                  </div>
+                  <ParamSlider
+                    label="地平线裁切 horizonCut"
+                    value={panel.horizonCut}
+                    min={0.05}
+                    max={1}
+                    step={0.005}
+                    onChange={(v) =>
+                      patchPanel({ horizonCut: v, horizonAuto: false })
+                    }
+                  />
+                  <ParamSlider
+                    label="掠射衰减 grazeFade"
+                    value={panel.grazeFade}
+                    min={0}
+                    max={0.8}
+                    step={0.01}
+                    onChange={(v) => patchPanel({ grazeFade: v })}
+                  />
+                  <div className={style.hint}>
+                    掠射衰减把与投影方向接近平行的表面淡出,0 为关闭;
+                    平视建议 0.2~0.3
+                  </div>
+                  <ParamSlider
+                    label="视角全强度 (°)"
+                    value={panel.viewAngleInner}
+                    min={0}
+                    max={180}
+                    step={1}
+                    onChange={(v) => patchPanel({ viewAngleInner: v })}
+                  />
+                  <ParamSlider
+                    label="视角淡出带宽 (°)"
+                    value={panel.viewAngleFade}
+                    min={0}
+                    max={120}
+                    step={1}
+                    onChange={(v) => patchPanel({ viewAngleFade: v })}
+                  />
+                  <div className={style.checkRow}>
+                    <Checkbox
+                      checked={panel.screenEnabled}
+                      onChange={(e) =>
+                        patchPanel({ screenEnabled: e.target.checked })
+                      }
+                    >
+                      偏离主轴时回退为视频幕布
+                    </Checkbox>
+                  </div>
+                  <ParamSlider
+                    label="幕布距离 (0=far)"
+                    value={panel.screenDistance}
+                    min={0}
+                    max={500}
+                    step={1}
+                    onChange={(v) => patchPanel({ screenDistance: v })}
+                  />
+                </>
+              ),
+            },
+            {
               key: "cam",
               label: "相机坐标 position",
               children: (

+ 75 - 11
src/loader/cesiumVideoProjector.ts

@@ -1,22 +1,30 @@
-import { computeVideoViewerFade } from "@/shared/cesiumOverlay";
+import { cartesianToVec3, computeVideoViewerFade } from "@/shared/cesiumOverlay";
 import type { Vec3 } from "@/shared/vec3";
 import type { VideoData } from "@/store/videosSlice";
 import {
   buildCesiumProjectorOptions,
   type VideoProjectorParams,
 } from "@/shared/videoProjector";
+import { directionFromEnuHeadingPitchRoll } from "@/shared/videoFrustum";
 import {
   createCesiumVideoProjector,
   type CesiumProjectorTool,
 } from "@/sdk/cesiumVideoProjection";
-import type { Viewer } from "cesium";
+import { attachVideoScreen, type VideoScreenHandle } from "@/loader/cesiumVideoScreen";
+import { Cartesian3, type Viewer } from "cesium";
 
 export type VideoProjectorSessionOptions = {
   showHelper?: boolean;
   autoOpacity?: boolean;
   fade?: {
-    position: Vec3;
-    videoData?: Pick<VideoData, "viewInnerRadius" | "viewFadeRadius">;
+    position?: Vec3;
+    videoData?: Pick<
+      VideoData,
+      | "viewInnerRadius"
+      | "viewFadeRadius"
+      | "viewAngleInner"
+      | "viewAngleFade"
+    >;
   };
 };
 
@@ -35,6 +43,22 @@ const destroyProjectorTool = (viewer: Viewer, tool: CesiumProjectorTool) => {
   tool.destroy();
 };
 
+const resolvePose = (params: VideoProjectorParams) => {
+  const position = Cartesian3.fromDegrees(
+    params.lon,
+    params.lat,
+    params.height,
+  );
+  return {
+    position,
+    direction: directionFromEnuHeadingPitchRoll(
+      position,
+      params.azimuth,
+      params.elevation,
+    ),
+  };
+};
+
 /** 预览 sync 与回放 create 走同一套 buildCesiumProjectorOptions,避免 setter 顺序导致偏差 */
 export const attachVideoProjector = (
   viewer: Viewer,
@@ -43,9 +67,31 @@ export const attachVideoProjector = (
   options?: VideoProjectorSessionOptions,
 ): VideoProjectorSession => {
   let tool: CesiumProjectorTool | null = null;
+  let screen: VideoScreenHandle | null = null;
   let baseFade = 1;
   let lastParams = initial;
 
+  const syncScreen = (params: VideoProjectorParams) => {
+    if (!params.screenEnabled) {
+      screen?.destroy();
+      screen = null;
+      return;
+    }
+
+    const pose = resolvePose(params);
+    const screenParams = {
+      position: pose.position,
+      direction: pose.direction,
+      fovDeg: params.fov,
+      aspect: params.aspect,
+      distance: params.screenDistance > 0 ? params.screenDistance : params.far,
+      rollDeg: params.roll,
+    };
+
+    if (screen) screen.update(screenParams);
+    else screen = attachVideoScreen(viewer, video, screenParams);
+  };
+
   const mount = (params: VideoProjectorParams) => {
     if (viewer.isDestroyed()) return;
     if (tool) destroyProjectorTool(viewer, tool);
@@ -54,21 +100,37 @@ export const attachVideoProjector = (
       buildCesiumProjectorOptions(params, video),
     ) as CesiumProjectorTool;
     lastParams = params;
+    syncScreen(params);
     updateOpacity();
   };
 
+  /**
+   * 观察者贴近摄像头主轴时用投影融合,偏离后交叉淡化到幕布:
+   * 投影会把未建模的动态目标抹在几何表面上,幕布始终保持原始画面。
+   */
   const updateOpacity = () => {
     if (!tool || tool.isDestroyed()) return;
-    let opacity = lastParams.opacity * baseFade;
-    if (options?.autoOpacity && options.fade) {
-      opacity *= computeVideoViewerFade(
+
+    const base = lastParams.opacity * baseFade;
+    const needViewFade = options?.autoOpacity || lastParams.screenEnabled;
+
+    let viewFade = 1;
+    if (needViewFade) {
+      const pose = resolvePose(lastParams);
+      viewFade = computeVideoViewerFade(
         viewer,
-        options.fade.position,
-        { x: 0, y: 0, z: 0 },
-        options.fade.videoData,
+        options?.fade?.position ?? cartesianToVec3(pose.position),
+        cartesianToVec3(pose.direction),
+        {
+          ...options?.fade?.videoData,
+          viewAngleInner: lastParams.viewAngleInner,
+          viewAngleFade: lastParams.viewAngleFade,
+        },
       );
     }
-    tool.opacity = opacity;
+
+    tool.opacity = base * viewFade;
+    screen?.setOpacity(lastParams.screenEnabled ? base * (1 - viewFade) : 0);
   };
 
   mount(initial);
@@ -93,6 +155,8 @@ export const attachVideoProjector = (
       viewer.scene.postRender.removeEventListener(onPostRender);
       if (tool) destroyProjectorTool(viewer, tool);
       tool = null;
+      screen?.destroy();
+      screen = null;
     },
   };
 };

+ 152 - 0
src/loader/cesiumVideoScreen.ts

@@ -0,0 +1,152 @@
+import {
+  BoundingSphere,
+  Cartesian3,
+  Color,
+  ComponentDatatype,
+  Geometry,
+  GeometryAttribute,
+  GeometryAttributes,
+  GeometryInstance,
+  Material,
+  MaterialAppearance,
+  PrimitiveType,
+  Primitive,
+  type Viewer,
+} from "cesium";
+import { computeFrustumFarCorners } from "@/shared/videoFrustum";
+import { toVerticalFovDeg } from "@/shared/videoHorizon";
+
+export type VideoScreenParams = {
+  position: Cartesian3;
+  direction: Cartesian3;
+  /** 与投影仪一致的 Cesium 视场角(aspect ≥ 1 时为水平角) */
+  fovDeg: number;
+  aspect: number;
+  /** 幕布距相机的距离(米) */
+  distance: number;
+  rollDeg?: number;
+};
+
+export type VideoScreenHandle = {
+  update: (params: VideoScreenParams) => void;
+  setOpacity: (opacity: number) => void;
+  destroy: () => void;
+};
+
+const buildQuadGeometry = (params: VideoScreenParams): Geometry => {
+  const corners = computeFrustumFarCorners(
+    params.position,
+    params.direction,
+    toVerticalFovDeg(params.fovDeg, params.aspect),
+    params.aspect,
+    Math.max(params.distance, 1),
+    params.rollDeg ?? 0,
+  );
+
+  const positions = new Float64Array(12);
+  corners.forEach((corner, i) => {
+    positions[i * 3] = corner.x;
+    positions[i * 3 + 1] = corner.y;
+    positions[i * 3 + 2] = corner.z;
+  });
+
+  // 幕布正对投影仪,法线取由幕布指回相机的方向
+  const center = Cartesian3.midpoint(corners[0], corners[2], new Cartesian3());
+  const normal = Cartesian3.normalize(
+    Cartesian3.subtract(params.position, center, new Cartesian3()),
+    new Cartesian3(),
+  );
+  const normals = new Float32Array(12);
+  for (let i = 0; i < 4; i++) {
+    normals[i * 3] = normal.x;
+    normals[i * 3 + 1] = normal.y;
+    normals[i * 3 + 2] = normal.z;
+  }
+
+  const attributes = new GeometryAttributes();
+  attributes.position = new GeometryAttribute({
+    componentDatatype: ComponentDatatype.DOUBLE,
+    componentsPerAttribute: 3,
+    values: positions,
+  });
+  attributes.normal = new GeometryAttribute({
+    componentDatatype: ComponentDatatype.FLOAT,
+    componentsPerAttribute: 3,
+    values: normals,
+  });
+  // 纹理 v=1 对应画面顶部,与 computeFrustumFarCorners 的左上→右上→右下→左下 顺序对齐
+  attributes.st = new GeometryAttribute({
+    componentDatatype: ComponentDatatype.FLOAT,
+    componentsPerAttribute: 2,
+    values: new Float32Array([0, 1, 1, 1, 1, 0, 0, 0]),
+  });
+
+  return new Geometry({
+    attributes,
+    indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
+    primitiveType: PrimitiveType.TRIANGLES,
+    boundingSphere: BoundingSphere.fromPoints(corners),
+  });
+};
+
+/**
+ * 视频幕布:观察者偏离摄像头主轴时用它替代投影融合。
+ * 投影会把未建模的动态目标抹在几何表面上,幕布则始终保持原始画面。
+ */
+export const attachVideoScreen = (
+  viewer: Viewer,
+  video: HTMLVideoElement,
+  initial: VideoScreenParams,
+): VideoScreenHandle => {
+  const material = Material.fromType(Material.ImageType, {
+    image: video,
+    color: new Color(1, 1, 1, 0),
+  });
+
+  let primitive: Primitive | null = null;
+  let opacity = 0;
+  let destroyed = false;
+
+  const rebuild = (params: VideoScreenParams) => {
+    if (destroyed || viewer.isDestroyed()) return;
+    if (primitive) viewer.scene.primitives.remove(primitive);
+
+    primitive = new Primitive({
+      geometryInstances: new GeometryInstance({
+        geometry: buildQuadGeometry(params),
+      }),
+      appearance: new MaterialAppearance({
+        material,
+        materialSupport: MaterialAppearance.MaterialSupport.TEXTURED,
+        translucent: true,
+        flat: true,
+        closed: false,
+      }),
+      asynchronous: false,
+      allowPicking: false,
+      show: opacity > 0,
+    });
+    viewer.scene.primitives.add(primitive);
+  };
+
+  rebuild(initial);
+
+  return {
+    update: rebuild,
+    setOpacity: (value: number) => {
+      if (destroyed) return;
+      opacity = Math.max(0, Math.min(1, value));
+      (material.uniforms.color as Color).alpha = opacity;
+      if (primitive) primitive.show = opacity > 0;
+    },
+    destroy: () => {
+      if (destroyed) return;
+      destroyed = true;
+      if (primitive && !viewer.isDestroyed()) {
+        viewer.scene.primitives.remove(primitive);
+      }
+      primitive = null;
+      if (!material.isDestroyed()) material.destroy();
+    },
+  };
+};

+ 60 - 2
src/sdk/cesiumVideoProjection/createCesiumVideoProjector.ts

@@ -18,6 +18,34 @@ import type {
 const computeQuadHomography = (corners: QuadCorners): Cesium.Matrix3 =>
   Cesium.Matrix3.fromRowMajorArray(computeQuadHomographyElements(corners));
 
+/**
+ * 椭球面法线,而非地心方向:两者在中纬度相差约 0.19°,
+ * 平视相机在百米外会体现为可见的横滚偏差。
+ * 视线接近竖直时该向量退化,退回北向。
+ */
+const resolveCameraUp = (
+  position: Cesium.Cartesian3,
+  direction: Cesium.Cartesian3,
+): Cesium.Cartesian3 => {
+  const surfaceNormal =
+    Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
+      position,
+      new Cesium.Cartesian3(),
+    ) ?? Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3());
+
+  const dir = Cesium.Cartesian3.normalize(direction, new Cesium.Cartesian3());
+  if (Math.abs(Cesium.Cartesian3.dot(dir, surfaceNormal)) < 0.999) {
+    return surfaceNormal;
+  }
+
+  const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);
+  return Cesium.Matrix4.multiplyByPointAsVector(
+    enu,
+    Cesium.Cartesian3.UNIT_Y,
+    new Cesium.Cartesian3(),
+  );
+};
+
 const rotateVectorByQuaternion = (
   vector: Cesium.Cartesian3,
   quat: Cesium.Quaternion,
@@ -56,9 +84,14 @@ export const createCesiumVideoProjector = (
       [1, 1],
       [0, 1],
     ] as QuadCorners,
+    grazeFade = 0,
+    horizonCut = 1,
   } = opts;
 
+  const shadowMapSize = opts.shadowMapSize ?? 2048;
+
   const ecef = new ECEF();
+  const scratchProjPositionEC = new Cesium.Cartesian3();
   let destroyed = false;
 
   const orientParams = {
@@ -140,7 +173,7 @@ export const createCesiumVideoProjector = (
       camPos,
       new Cesium.Cartesian3(),
     );
-    cam.up = Cesium.Cartesian3.normalize(camPos, new Cesium.Cartesian3());
+    cam.up = resolveCameraUp(camPos, cam.direction);
 
     const dis = Cesium.Cartesian3.distance(tgtPos, camPos);
 
@@ -166,6 +199,7 @@ export const createCesiumVideoProjector = (
       cascadesEnabled: false,
       context: (viewer.scene as unknown as { context: unknown }).context,
       pointLightRadius: dis,
+      size: shadowMapSize,
     });
 
     viewShadowMap.fromLightSource = false;
@@ -179,7 +213,7 @@ export const createCesiumVideoProjector = (
       Cesium.Cartesian3.subtract(tgtPos, camPos, new Cesium.Cartesian3()),
       new Cesium.Cartesian3(),
     );
-    let up = Cesium.Cartesian3.normalize(camPos, new Cesium.Cartesian3());
+    let up = resolveCameraUp(camPos, dir);
     const cam = new Cesium.Camera(viewer.scene);
     cam.position = camPos;
     cam.direction = dir;
@@ -281,6 +315,14 @@ export const createCesiumVideoProjector = (
         quadHomography: () => quadHomography,
         cropPolygonCount: () => cropPolygon.length,
         cropPolygon: () => cropPolygonPacked,
+        projPositionEC: () =>
+          Cesium.Matrix4.multiplyByPoint(
+            viewer.camera.viewMatrix,
+            cameraPosition,
+            scratchProjPositionEC,
+          ),
+        grazeFade: () => grazeFade,
+        horizonCut: () => horizonCut,
       },
     });
     viewer.scene.postProcessStages.add(postProcess);
@@ -543,6 +585,22 @@ export const createCesiumVideoProjector = (
       },
       enumerable: true,
     },
+    grazeFade: {
+      get: () => grazeFade,
+      set: (val: number) => {
+        if (destroyed) return;
+        grazeFade = Math.max(0, Math.min(1, val));
+      },
+      enumerable: true,
+    },
+    horizonCut: {
+      get: () => horizonCut,
+      set: (val: number) => {
+        if (destroyed) return;
+        horizonCut = Math.max(0, Math.min(1, val));
+      },
+      enumerable: true,
+    },
     source: {
       set: (source: TextureSource) => {
         if (destroyed) return;

+ 60 - 7
src/sdk/cesiumVideoProjection/fragmentShader.ts

@@ -4,6 +4,8 @@ export const CROP_POLYGON_MAX = 16;
 /**
  * 基于 vid3d-projection cesium frag,扩展:
  * - 多边形裁剪与边距羽化
+ * - 掠射角衰减与斜率缩放深度偏移(平视摄像头必需)
+ * - 地平线裁切
  */
 export const cesiumVideoProjectionFrag = /* glsl */ `
 precision highp float;
@@ -23,6 +25,9 @@ uniform vec4 cropRect;
 uniform mat3 quadHomography;
 uniform int cropPolygonCount;
 uniform vec2 cropPolygon[${CROP_POLYGON_MAX}];
+uniform vec3 projPositionEC;
+uniform float grazeFade;
+uniform float horizonCut;
 
 in vec2 v_textureCoordinates;
 out vec4 czm_FragColor;
@@ -102,24 +107,71 @@ void main() {
 
   float depth = getDepth(currD);
   vec4 positionEC = toEye(v_textureCoordinates, depth);
+
+  // 掠射角判据依赖表面法线,后处理阶段只能由深度缓冲的屏幕空间导数重建
+  vec3 normalEC = normalize(cross(dFdx(positionEC.xyz), dFdy(positionEC.xyz)));
+  if (dot(normalEC, -positionEC.xyz) < 0.0) {
+    normalEC = -normalEC;
+  }
+
+  vec3 toProj = projPositionEC - positionEC.xyz;
+  vec3 lightEC = normalize(toProj);
+  float ndl = dot(normalEC, lightEC);
+
+  float grazeFactor = 1.0;
+  if (grazeFade > 0.0) {
+    grazeFactor = smoothstep(0.0, grazeFade, ndl);
+    if (grazeFactor <= 0.0) {
+      czm_FragColor = color;
+      return;
+    }
+  }
+
   czm_shadowParameters shadowParameters;
   shadowParameters.texelStepSize = shadowMap_texelSizeDepthBiasAndNormalShadingSmooth.xy;
   shadowParameters.depthBias = shadowMap_texelSizeDepthBiasAndNormalShadingSmooth.z;
-  shadowParameters.depthBias *= max(depth * 0.01, 1.0);
+
+  // 掠射角下相邻 texel 的真实深度差随 tan(入射角) 增长,常量 bias 必然出条纹
+  float slope = clamp(sqrt(max(1.0 - ndl * ndl, 0.0)) / max(ndl, 0.05), 0.0, 20.0);
+  shadowParameters.depthBias *= 1.0 + slope;
 
   vec4 shadowPosition = shadowMapMatrix * positionEC;
+  if (shadowPosition.w <= 0.0) {
+    czm_FragColor = color;
+    return;
+  }
   shadowPosition /= shadowPosition.w;
-  shadowPosition.z -= projBias;
+  shadowPosition.z -= projBias * (1.0 + slope);
+
+  if (any(lessThan(shadowPosition.xyz, vec3(0.0))) ||
+      any(greaterThan(shadowPosition.xyz, vec3(1.0)))) {
+    czm_FragColor = color;
+    return;
+  }
 
   shadowParameters.texCoords = shadowPosition.xy;
   shadowParameters.depth = shadowPosition.z;
 
   float visibility = _czm_shadowVisibility(shadowMapTexture, shadowParameters);
+  if (visibility <= 0.0) {
+    czm_FragColor = color;
+    return;
+  }
 
   vec3 projCoords = vec3(shadowPosition.xy, 1.0);
   projCoords = quadHomography * projCoords;
   projCoords /= projCoords.z;
 
+  float horizonFactor = 1.0;
+  if (horizonCut < 1.0) {
+    float band = max(featherAmount, 0.01);
+    horizonFactor = 1.0 - smoothstep(horizonCut - band, horizonCut, projCoords.y);
+    if (horizonFactor <= 0.0) {
+      czm_FragColor = color;
+      return;
+    }
+  }
+
   float edgeFactor = 1.0;
 
   if (cropPolygonCount >= 3) {
@@ -142,11 +194,12 @@ void main() {
   }
 
   vec4 videoColor = texture(videoTexture, projCoords.xy);
+  float alpha = clamp(
+    opacity * edgeFactor * visibility * grazeFactor * horizonFactor,
+    0.0,
+    1.0
+  );
 
-  if (visibility == 1.0) {
-    czm_FragColor = mix(color, vec4(videoColor.rgb * intensity, 1.0), opacity * edgeFactor);
-  } else {
-    czm_FragColor = color;
-  }
+  czm_FragColor = mix(color, vec4(videoColor.rgb * intensity, 1.0), alpha);
 }
 `;

+ 8 - 0
src/sdk/cesiumVideoProjection/types.ts

@@ -39,6 +39,12 @@ export type CesiumProjectorOptions = {
   cropPolygon?: CropPolygonUv;
   quadCorners?: QuadCorners;
   showFarPlane?: boolean;
+  /** 掠射角衰减阈值:表面法线与投影方向夹角余弦低于此值时淡出,0 关闭 */
+  grazeFade?: number;
+  /** 地平线裁切:投影 UV 纵轴上界,1 表示不裁切 */
+  horizonCut?: number;
+  /** shadow map 边长,平视需要更高分辨率 */
+  shadowMapSize?: number;
 };
 
 export type CesiumProjectorTool = {
@@ -67,4 +73,6 @@ export type CesiumProjectorTool = {
   cropPolygon: CropPolygonUv;
   quadCorners: QuadCorners;
   showFarPlane: boolean;
+  grazeFade: number;
+  horizonCut: number;
 };

+ 7 - 0
src/sdk/videoPick/convert.ts

@@ -102,6 +102,13 @@ export const buildVideoDevicePickResult = (
       flipX: input.flipX,
       flipY: input.flipY,
       focalLength: input.focalLength,
+      grazeFade: input.grazeFade,
+      horizonCut: input.horizonCut,
+      horizonAuto: input.horizonAuto,
+      screenEnabled: input.screenEnabled,
+      screenDistance: input.screenDistance,
+      viewAngleInner: input.viewAngleInner,
+      viewAngleFade: input.viewAngleFade,
     },
   };
 };

+ 62 - 11
src/shared/cesiumOverlay.ts

@@ -3,6 +3,7 @@ import {
   Cartesian3,
   Cesium3DTileset,
   defined,
+  Math as CesiumMath,
   Matrix4,
   Ray,
   SceneTransforms,
@@ -379,25 +380,75 @@ export const flyCesiumToVideo = (
 
 const smoothstep01 = (t: number) => t * t * (3 - 2 * t);
 
-/** 根据观察者到摄像头位置的距离计算可见度 */
-export const computeVideoViewerFade = (
+export type VideoViewFadeOptions = Partial<
+  Pick<
+    VideoData,
+    | "viewInnerRadius"
+    | "viewFadeRadius"
+    | "viewAngleInner"
+    | "viewAngleFade"
+  >
+>;
+
+const computeDistanceFade = (
   viewer: Viewer,
   position: Vec3,
-  _direction: Vec3,
-  options?: Pick<VideoData, "viewInnerRadius" | "viewFadeRadius">,
+  options?: VideoViewFadeOptions,
 ): number => {
-  if (viewer.isDestroyed()) return 0;
-
   const innerRadius = options?.viewInnerRadius ?? 150;
   const fadeRadius = options?.viewFadeRadius ?? 80;
+  if (fadeRadius <= 0) return 1;
 
-  const videoPos = vec3ToCartesian(position);
-  const dist = Cartesian3.distance(viewer.camera.positionWC, videoPos);
-
+  const dist = Cartesian3.distance(
+    viewer.camera.positionWC,
+    vec3ToCartesian(position),
+  );
   if (dist <= innerRadius) return 1;
   if (dist >= innerRadius + fadeRadius) return 0;
 
-  const t = (dist - innerRadius) / fadeRadius;
-  return 1 - smoothstep01(t);
+  return 1 - smoothstep01((dist - innerRadius) / fadeRadius);
+};
+
+/**
+ * 投影纹理只有在投影仪自身视点上才严格正确,观察者偏离主轴越多、
+ * 未建模的动态目标被抹开得越明显,因此按视线夹角淡出。
+ */
+const computeAngleFade = (
+  viewer: Viewer,
+  direction: Vec3,
+  options?: VideoViewFadeOptions,
+): number => {
+  const angleInner = options?.viewAngleInner ?? 40;
+  const angleFade = options?.viewAngleFade ?? 35;
+  if (angleFade <= 0) return 1;
+
+  const axis = vec3ToCartesian(direction);
+  if (Cartesian3.magnitudeSquared(axis) < 1e-12) return 1;
+
+  const cos = Cartesian3.dot(
+    Cartesian3.normalize(axis, new Cartesian3()),
+    viewer.camera.directionWC,
+  );
+  const deg = CesiumMath.toDegrees(Math.acos(CesiumMath.clamp(cos, -1, 1)));
+
+  if (deg <= angleInner) return 1;
+  if (deg >= angleInner + angleFade) return 0;
+
+  return 1 - smoothstep01((deg - angleInner) / angleFade);
+};
+
+/** 观察者可见度:距离衰减 × 视线夹角衰减 */
+export const computeVideoViewerFade = (
+  viewer: Viewer,
+  position: Vec3,
+  direction: Vec3,
+  options?: VideoViewFadeOptions,
+): number => {
+  if (viewer.isDestroyed()) return 0;
+
+  const distanceFade = computeDistanceFade(viewer, position, options);
+  if (distanceFade <= 0) return 0;
+
+  return distanceFade * computeAngleFade(viewer, direction, options);
 };
 

+ 54 - 0
src/shared/videoHorizon.ts

@@ -0,0 +1,54 @@
+import { Math as CesiumMath } from "cesium";
+
+/** 自动裁切时在地平线下方额外留出的安全带,掠射最严重的一圈直接切掉 */
+export const HORIZON_SAFE_MARGIN = 0.04;
+
+/**
+ * Cesium PerspectiveFrustum.fov 在 aspect ≥ 1 时表示水平视场角,否则表示垂直视场角。
+ * 地平线解算需要的是垂直视场角。
+ */
+export const toVerticalFovDeg = (fovDeg: number, aspect: number): number => {
+  if (aspect < 1) return fovDeg;
+  const halfTan = Math.tan(CesiumMath.toRadians(fovDeg) / 2);
+  return CesiumMath.toDegrees(
+    2 * Math.atan(halfTan / Math.max(aspect, 1e-6)),
+  );
+};
+
+/**
+ * 地平线在投影 UV 纵轴上的位置。
+ * 地平线相对光轴的仰角等于 -elevation,针孔模型下映射到 0.5 + 0.5·tan(-elevation)/tan(fovV/2)。
+ * 返回值 ≥ 1 表示地平线在画面之上(俯瞰相机看不到地平线)。
+ */
+export const computeHorizonV = (
+  elevationDeg: number,
+  fovDeg: number,
+  aspect: number,
+): number => {
+  const halfTan = Math.tan(CesiumMath.toRadians(toVerticalFovDeg(fovDeg, aspect)) / 2);
+  if (!Number.isFinite(halfTan) || halfTan <= 1e-6) {
+    return Number.POSITIVE_INFINITY;
+  }
+
+  const pitchDeg = CesiumMath.clamp(-elevationDeg, -89.9, 89.9);
+  const v = 0.5 + (0.5 * Math.tan(CesiumMath.toRadians(pitchDeg))) / halfTan;
+  return Number.isFinite(v) ? v : Number.POSITIVE_INFINITY;
+};
+
+export const isHorizonInFrame = (
+  elevationDeg: number,
+  fovDeg: number,
+  aspect: number,
+): boolean => computeHorizonV(elevationDeg, fovDeg, aspect) < 1;
+
+/** 自动地平线裁切值;地平线不在画面内时返回 1(不裁切) */
+export const computeAutoHorizonCut = (
+  elevationDeg: number,
+  fovDeg: number,
+  aspect: number,
+  margin = HORIZON_SAFE_MARGIN,
+): number => {
+  const v = computeHorizonV(elevationDeg, fovDeg, aspect);
+  if (!Number.isFinite(v) || v >= 1) return 1;
+  return CesiumMath.clamp(v - margin, 0.05, 1);
+};

+ 53 - 1
src/shared/videoProjector.ts

@@ -4,8 +4,13 @@ import {
 import {
   fovToFocalLength,
 } from "@/shared/videoFrustum";
+import { computeAutoHorizonCut } from "@/shared/videoHorizon";
 import type { Vec3 } from "@/shared/vec3";
-import type { VideoData, VideoProjectorFields } from "@/storeDB/videos";
+import {
+  VIDEO_VIEW_FADE_DEFAULTS,
+  type VideoData,
+  type VideoProjectorFields,
+} from "@/storeDB/videos";
 import {
   Cartesian3,
   Cartographic,
@@ -65,8 +70,26 @@ export type VideoProjectorParams = {
   quadCorners: QuadCornersUv;
   flipX?: boolean;
   flipY?: boolean;
+  /** 掠射角衰减阈值(N·L 下界),0 关闭 */
+  grazeFade: number;
+  /** 地平线裁切上界,1 不裁切 */
+  horizonCut: number;
+  /** horizonCut 跟随 elevation/fov/aspect 自动推算 */
+  horizonAuto: boolean;
+  /** 观察者偏离主轴时回退为视频幕布 */
+  screenEnabled: boolean;
+  /** 幕布距相机距离,0 表示取 far */
+  screenDistance: number;
+  viewAngleInner: number;
+  viewAngleFade: number;
 };
 
+/** horizonAuto 打开时由视锥参数推算,否则用手动值 */
+export const resolveHorizonCut = (params: VideoProjectorParams): number =>
+  params.horizonAuto
+    ? computeAutoHorizonCut(params.elevation, params.fov, params.aspect)
+    : params.horizonCut;
+
 export const lonLatHeightToEcef = (
   lon: number,
   lat: number,
@@ -125,6 +148,7 @@ export const VID3D_DEMO_DEFAULTS = {
   azimuth: 0,
   elevation: -30,
   roll: 0,
+  grazeFade: 0.25,
 } as const;
 
 export const createDefaultProjectorParams = (options: {
@@ -162,6 +186,13 @@ export const createDefaultProjectorParams = (options: {
     [1, 1],
     [0, 1],
   ] as QuadCornersUv,
+  grazeFade: VID3D_DEMO_DEFAULTS.grazeFade,
+  horizonCut: 1,
+  horizonAuto: true,
+  screenEnabled: false,
+  screenDistance: 0,
+  viewAngleInner: VIDEO_VIEW_FADE_DEFAULTS.viewAngleInner,
+  viewAngleFade: VIDEO_VIEW_FADE_DEFAULTS.viewAngleFade,
 });
 
 /** 由 lon/lat/hei 得到 ECEF */
@@ -210,6 +241,14 @@ export const videoDataToProjectorParams = (
     quadCorners: data.quadCorners.map((c) => [...c]) as QuadCornersUv,
     flipX: data.flipX,
     flipY: data.flipY,
+    grazeFade: data.grazeFade ?? VID3D_DEMO_DEFAULTS.grazeFade,
+    horizonCut: data.horizonCut ?? 1,
+    horizonAuto: data.horizonAuto ?? true,
+    screenEnabled: data.screenEnabled ?? false,
+    screenDistance: data.screenDistance ?? 0,
+    viewAngleInner:
+      data.viewAngleInner ?? VIDEO_VIEW_FADE_DEFAULTS.viewAngleInner,
+    viewAngleFade: data.viewAngleFade ?? VIDEO_VIEW_FADE_DEFAULTS.viewAngleFade,
 });
 
 export const projectorParamsToVideoProjectorFields = (
@@ -237,6 +276,13 @@ export const projectorParamsToVideoProjectorFields = (
   flipX: params.flipX ?? false,
   flipY: params.flipY ?? false,
   focalLength: fovToFocalLength(params.fov),
+  grazeFade: params.grazeFade,
+  horizonCut: resolveHorizonCut(params),
+  horizonAuto: params.horizonAuto,
+  screenEnabled: params.screenEnabled,
+  screenDistance: params.screenDistance,
+  viewAngleInner: params.viewAngleInner,
+  viewAngleFade: params.viewAngleFade,
 });
 
 export const buildVideoProjectorParamsFromData = (
@@ -273,6 +319,8 @@ export const buildCesiumProjectorOptions = (
     params.flipX,
     params.flipY,
   ),
+  grazeFade: params.grazeFade,
+  horizonCut: resolveHorizonCut(params),
 });
 
 export const applyVideoProjectorParams = (
@@ -298,6 +346,8 @@ export const applyVideoProjectorParams = (
     params.flipX,
     params.flipY,
   );
+  tool.grazeFade = params.grazeFade;
+  tool.horizonCut = resolveHorizonCut(params);
 };
 
 type CesiumProjectorToolLike = {
@@ -317,6 +367,8 @@ type CesiumProjectorToolLike = {
   cropRect: [number, number, number, number];
   cropPolygon: CropPolygonUv;
   quadCorners: CesiumProjectorOptions["quadCorners"];
+  grazeFade: number;
+  horizonCut: number;
 };
 
 /** 钢笔绘制后同步外接矩形,便于兼容旧字段 */

+ 3 - 1
src/storeDB/monitorVideos.ts

@@ -1,4 +1,4 @@
-import type { VideoData } from "./videos";
+import { DEFAULT_VIDEO_PROJECTOR_FIELDS, type VideoData } from "./videos";
 
 export type MonitorVideoItem = VideoData & {
   name: string;
@@ -15,6 +15,7 @@ export const MONITOR_VIDEOS: MonitorVideoItem[] = [
     url: "/1.mp4",
     width: 756,
     height: 1080,
+    ...DEFAULT_VIDEO_PROJECTOR_FIELDS,
     lon: 113.59560626,
     lat: 22.36703002,
     hei: 23,
@@ -56,6 +57,7 @@ export const MONITOR_VIDEOS: MonitorVideoItem[] = [
     url: "/2.mp4",
     width: 1920,
     height: 1080,
+    ...DEFAULT_VIDEO_PROJECTOR_FIELDS,
     lon: 113.59560812,
     lat: 22.3670266,
     hei: 23,

+ 26 - 0
src/storeDB/videos.ts

@@ -38,8 +38,27 @@ export type VideoProjectorFields = {
   flipY: boolean;
   /** 设备焦距(与 fov 对应,拾取完成时一并输出) */
   focalLength: number;
+  /** 掠射角衰减阈值(N·L 下界),0 关闭;平视建议 0.2~0.3 */
+  grazeFade: number;
+  /** 地平线裁切:投影 UV 纵轴上界,1 不裁切 */
+  horizonCut: number;
+  /** horizonCut 由 elevation/fov/aspect 自动推算 */
+  horizonAuto: boolean;
+  /** 观察者偏离主轴时回退为视频幕布 */
+  screenEnabled: boolean;
+  /** 幕布距相机距离,0 表示取 far */
+  screenDistance: number;
+  /** 视线夹角全强度范围(度) */
+  viewAngleInner: number;
+  /** 视线夹角淡出带宽(度),0 关闭角度衰减 */
+  viewAngleFade: number;
 };
 
+export const VIDEO_VIEW_FADE_DEFAULTS = {
+  viewAngleInner: 40,
+  viewAngleFade: 35,
+} as const;
+
 export const DEFAULT_VIDEO_PROJECTOR_FIELDS: VideoProjectorFields = {
   lon: 0,
   lat: 0,
@@ -63,6 +82,13 @@ export const DEFAULT_VIDEO_PROJECTOR_FIELDS: VideoProjectorFields = {
   flipX: false,
   flipY: false,
   focalLength: fovToFocalLength(VID3D_DEMO_DEFAULTS.fov),
+  grazeFade: VID3D_DEMO_DEFAULTS.grazeFade,
+  horizonCut: 1,
+  horizonAuto: true,
+  screenEnabled: false,
+  screenDistance: 0,
+  viewAngleInner: VIDEO_VIEW_FADE_DEFAULTS.viewAngleInner,
+  viewAngleFade: VIDEO_VIEW_FADE_DEFAULTS.viewAngleFade,
 };
 
 export type VideoData = {

+ 3 - 2
src/views/stage.tsx

@@ -24,6 +24,7 @@ import { sampleEntPositions } from "@/shared/entHotspot";
 import type { VideoProjectorParams } from "@/shared/videoProjector";
 import type { EntType } from "@/apis/types";
 import type { HotsoptData } from "@/storeDB/hotsopt";
+import { Button } from "antd";
 
 type EditorSession = {
   siteId: number;
@@ -191,7 +192,7 @@ export const Index = () => {
           onCancel={resetPick}
         />
       )}
-      {/* <Button
+      <Button
         type={pickMode === "video" || showEditor ? "primary" : "default"}
         style={{ position: "fixed", top: 100, left: 0, zIndex: 999999 }}
         onClick={() => {
@@ -200,7 +201,7 @@ export const Index = () => {
         }}
       >
         {pickMode === "video" || showEditor ? "取消" : "拾取摄像头安装点"}
-      </Button> */}
+      </Button>
       {pickEnabled && pickHint && (
         <div
           style={{