Skip to content

Pixi.js 半圆柱贴图渲染

通过 Pixi.js 把一张资源图片,渲染到半圆柱的圆面上。

效果演示

核心源码

pixi v8 版本代码实现

vue
<template>
  <div class="pixi-canvas-wrapper" ref="wrapper">
    <div ref="canvasContainer" class="canvas-container"></div>
    <div v-if="!hasImage" class="placeholder">
      请点击上方按钮上传图片<br>以查看圆柱体效果
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as PIXI from 'pixi.js'

const props = defineProps<{
  imageFile: File | null
}>()

const canvasContainer = ref<HTMLElement | null>(null)
const wrapper = ref<HTMLElement | null>(null)
const hasImage = ref(false)

let app: PIXI.Application | null = null
let stageContainer: PIXI.Container | null = null

// 初始化 Pixi 应用
const initPixi = async () => {
  if (!canvasContainer.value) return

  app = new PIXI.Application()
  await app.init({
    width: 800,
    height: 600,
    background: '#1a1a1a',
    antialias: true,
    resolution: window.devicePixelRatio || 1,
    autoDensity: true
  })

  canvasContainer.value.appendChild(app.canvas)

  stageContainer = new PIXI.Container()
  stageContainer.x = app.screen.width / 2
  stageContainer.y = app.screen.height / 2
  app.stage.addChild(stageContainer)
}

// 创建圆柱体网格
const createCylinderPlane = async (texture: PIXI.Texture) => {
  if (!stageContainer || !app) return

  // 清理旧内容
  stageContainer.removeChildren()

  // --- 配置参数 ---
  const segmentsX = 40 // X轴分段数
  const segmentsY = 2  // Y轴分段数

  const imgWidth = texture.width
  const imgHeight = texture.height

  // 创建 SimplePlane
  const plane = new PIXI.MeshPlane({ texture, verticesX: segmentsX + 1, verticesY: segmentsY })

  // --- 数学变换 ---
  const buffer = plane.geometry.getBuffer('aPosition')
  const data = buffer.data as Float32Array

  // 理论圆柱半径
  const radius = imgWidth / Math.PI

  for (let i = 0; i < data.length; i += 2) {
    const originalY = data[i + 1]

    const vertexIndex = i / 2
    const cols = segmentsX + 1
    const colIndex = vertexIndex % cols

    // 归一化比例 k (0.0 ~ 1.0)
    const k = colIndex / segmentsX

    // 映射到角度 (-90度 ~ +90度)
    const angle = (k - 0.5) * Math.PI

    // 圆柱投影公式
    const cylinderX = radius * Math.sin(angle)
    const depthZ = radius * Math.cos(angle)

    // 应用坐标
    data[i] = cylinderX

    // 透视效果
    const perspectiveFactor = 0.85 + (depthZ / radius) * 0.15

    // 调整 Y 轴
    const centerY = imgHeight / 2
    const distY = originalY - centerY
    data[i + 1] = centerY + distY * perspectiveFactor
  }

  buffer.update()

  // 居中平面
  plane.x = 0

  // 由于我们手动将顶点修改为以 0 为中心([-R, R]),
  // 所以 Pivot X 应该设为 0,而不是 imgWidth/2。
  // Y 轴仍然保持原始坐标系([0, H]),所以 Pivot Y 设为 imgHeight/2 以便垂直居中。
  plane.pivot.set(0, imgHeight / 2)

  stageContainer.addChild(plane)

  // --- 自动缩放适应画布 ---
  const boundsWidth = radius * 2
  const boundsHeight = imgHeight

  // 留出 10% 边距
  const padding = 0.9
  const scaleX = (app.screen.width * padding) / boundsWidth
  const scaleY = (app.screen.height * padding) / boundsHeight

  // 保持比例,且最大缩放为 1 (不放大)
  const scale = Math.min(scaleX, scaleY, 1)

  stageContainer.scale.set(scale)
}

watch(() => props.imageFile, (newFile) => {
  if (newFile) {
    hasImage.value = true
    const reader = new FileReader()
    reader.onload = async (e) => {
      if (e.target?.result && typeof e.target.result === 'string') {
        const texture = await PIXI.Assets.load(e.target.result)
        createCylinderPlane(texture)
      }
    }
    reader.readAsDataURL(newFile)
  }
})

onMounted(() => {
  initPixi()
})

onUnmounted(() => {
  if (app) {
		// @ts-ignore
    app.destroy(true, { children: true, texture: true, baseTexture: true })
  }
})
</script>

<style scoped>
.pixi-canvas-wrapper {
  position: relative;
  width: 100%;
  height: 600px;
  background: #1a1a1a;
  border-radius: 8px;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
}

.canvas-container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

.placeholder {
  position: absolute;
  color: #fff;
  text-align: center;
  font-size: 18px;
  pointer-events: none;
  line-height: 1.5;
}
</style>

pixi v4 版本代码实现

vue
<template>
  <div class="pixi-canvas-wrapper" ref="wrapper">
    <div ref="canvasContainer" class="canvas-container"></div>
    <div v-if="!hasImage" class="placeholder">
      请点击上方按钮上传图片<br>以查看 Pixi V4 圆柱体效果
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'

const props = defineProps<{
  imageFile: File | null
}>()

const canvasContainer = ref<HTMLElement | null>(null)
const wrapper = ref<HTMLElement | null>(null)
const hasImage = ref(false)

let PIXI: any = null
let app: any = null
let stageContainer: any = null

const initPixi = async () => {
  if (!canvasContainer.value) return

  if (!PIXI) {
    // @ts-ignore
    if (typeof window !== 'undefined' && !window.global) {
      // @ts-ignore
      window.global = window
    }
    // @ts-ignore
    const pixiModule = await import('pixi.js-v4')
    // Handle both default export (common in bundlers) and named export
    PIXI = pixiModule.default || pixiModule
  }

  // @ts-ignore
  app = new PIXI.Application({
    width: 800,
    height: 600,
    backgroundColor: 0x1a1a1a,
    antialias: true,
    resolution: window.devicePixelRatio || 1,
    autoDensity: true
  })

  canvasContainer.value.appendChild(app.view)

  stageContainer = new PIXI.Container()
  const resolution = app.renderer.resolution || 1
  stageContainer.x = (app.renderer.width / resolution) / 2
  stageContainer.y = (app.renderer.height / resolution) / 2
  app.stage.addChild(stageContainer)

  if (props.imageFile) {
    loadImage(props.imageFile)
  }
}

const loadImage = (file: File) => {
  if (!PIXI) return

  const reader = new FileReader()
  reader.onload = (e) => {
    if (e.target?.result && typeof e.target.result === 'string') {
      const texture = PIXI.Texture.from(e.target.result)
      createCylinderMesh(texture)
    }
  }
  reader.readAsDataURL(file)
}

/** 手动创建圆柱体 Mesh (PixiJS v4) */
const createCylinderMesh = (texture: any) => {
  if (!stageContainer || !app || !PIXI) return

  // 确保纹理已加载且尺寸有效
  if (!texture.baseTexture.hasLoaded || texture.width <= 1 || texture.height <= 1) {
    texture.once('update', () => createCylinderMesh(texture))
    return
  }

  stageContainer.removeChildren()

  const segmentsX = 40
  const segmentsY = 2
  const verticesX = segmentsX + 1
  const verticesY = segmentsY

  const imgWidth = texture.width
  const imgHeight = texture.height
  const radius = imgWidth / Math.PI

  // 使用 Plane 自动生成拓扑结构
  // @ts-ignore
  const mesh = new PIXI.mesh.Plane(texture, verticesX, verticesY)

  // 【防止重置】覆盖 refresh 方法,防止 Texture 更新时重置顶点
  // @ts-ignore
  mesh.refresh = () => {};
  // @ts-ignore
  mesh._refresh = () => {};

  const vertices = mesh.vertices

  for (let i = 0; i < vertices.length / 2; i++) {
    const col = i % verticesX
    const row = Math.floor(i / verticesX)

    const u = col / segmentsX
    const v = row / (segmentsY - 1)

    const angle = (u - 0.5) * Math.PI
    
    const cylinderX = radius * Math.sin(angle)
    const depthZ = radius * Math.cos(angle)

    const originalY = v * imgHeight
    const centerY = imgHeight / 2
    
    // 透视因子
    const perspectiveFactor = 0.85 + (depthZ / radius) * 0.15
    
    const distY = originalY - centerY
    const finalY = centerY + distY * perspectiveFactor

    vertices[i * 2] = cylinderX
    vertices[i * 2 + 1] = finalY
  }

  mesh.x = 0
  mesh.pivot.set(0, imgHeight / 2)

  // 必须把 dirty 标记设为 true
  // @ts-ignore
  if (mesh.dirty) mesh.dirty++;

  stageContainer.addChild(mesh)

  // --- 缩放计算优化 ---
  // 使用显式的逻辑尺寸 (800x600) 进行计算,与 initPixi 中的配置保持严格一致
  // 这样可以规避 renderer 尺寸、resolution 或 DOM 尺寸在不同环境下的不确定性
  const APP_WIDTH = 800
  const APP_HEIGHT = 600
  
  const boundsWidth = radius * 2
  const boundsHeight = imgHeight
  
  // 增加边距到 0.65 (留 35% 空隙),确保上下肯定能完整显示
  const padding = 0.65
  
  const scaleX = (APP_WIDTH * padding) / boundsWidth
  const scaleY = (APP_HEIGHT * padding) / boundsHeight
  
  // 保持比例缩放,且最大不超过 1
  const scale = Math.min(scaleX, scaleY, 1)

  stageContainer.scale.set(scale)
  
  // 确保居中到逻辑画布中心
  stageContainer.x = APP_WIDTH / 2
  stageContainer.y = APP_HEIGHT / 2
}

watch(() => props.imageFile, (newFile) => {
  if (newFile) {
    hasImage.value = true
    loadImage(newFile)
  }
})

onMounted(() => {
  initPixi()
})

onUnmounted(() => {
  if (app) {
    app.destroy(true)
  }
})
</script>

<style scoped>
.pixi-canvas-wrapper {
  position: relative;
  width: 100%;
  height: 600px;
  background: #1a1a1a;
  border-radius: 8px;
  overflow: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
}

.canvas-container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}

:deep(canvas) {
  display: block;
}

.placeholder {
  position: absolute;
  color: #fff;
  text-align: center;
  font-size: 18px;
  pointer-events: none;
  line-height: 1.5;
}
</style>

核心原理

要在 2D 屏幕上模拟半圆柱,我们需要修改网格顶点的坐标:

  1. X 轴映射 (Mapping): 图片的水平 x 坐标原本是线性的。在圆柱体上,图片是“卷”起来的。
    • 原本的 01 (纹理坐标) 对应圆柱的角度 π2π2
    • 屏幕上的 x 坐标应该是 Rsin(θ)
    • 这将导致图像中间宽,两边窄(透视压缩)。
  2. Y 轴透视 (Perspective): 为了增加立体感,离屏幕越远(圆柱边缘),图像应该显得稍微小一点(近大远小)。
  3. 光影 (Shading): 圆柱体边缘应该比中间暗。我们会通过叠加一层带有透明度渐变的遮罩来实现伪 3D 光照。

贡献者

The avatar of contributor named as ruan-cat ruan-cat
The avatar of contributor named as Claude Opus 4.5 Claude Opus 4.5

页面历史

最近更新