来在网页上用threejs画个蛋白质吧

这是一篇介绍在网页上使用前端技术实现蛋白质球棍模型和彩带模型渲染的文章;本文分为以下几个模块介绍:

  1. 蛋白质基本结构的介绍和在网页端渲染的意义及作用
  2. 蛋白质结构存储文件格式的介绍
  3. 使用threejs,react完成渲染的基本步骤和数学原理

蛋白质基本结构的介绍和在网页端渲染的意义及作用

蛋白质基本结构的介绍

蛋白质是什么

蛋白质(Protein)是生命活动的物质承担者,是由一条或多条多肽链在空间中折叠而成的复杂生物大分子。它们是所有生命体中含量最丰富、功能最核心的有机大分子,直接执行并调控细胞内的绝大多数生理生化过程。

没有蛋白质,就没有生命的表达。蛋白质在生物体内扮演着"分子机器"的角色,其功能几乎涵盖了生命的每一个角落,生化催化(酶),结构与物理支撑,物质运输等等

可以这么说,你就是一团蛋白质

蛋白质的结构

蛋白质的化学组成与构建

  • 基本单元(氨基酸): 蛋白质的基础构建块是20种标准氨基酸。所有氨基酸都包含碳、氢、氧、氮(部分含有硫),并拥有相同的骨架(一个氨基和一个羧基)。它们的不同之处在于侧链(R基),这决定了各个氨基酸特定的化学性质(如亲水、疏水、带正电或负电)。
  • 线性连接(多肽链): 在细胞的核糖体中,氨基酸分子通过脱水缩合反应,由肽键(一种强共价键)首尾相连,形成长长的线性链条,称为多肽链。一条多肽链可以包含几十到数万个氨基酸。
    • 空间折叠: 线性多肽链合成后,必须依靠氨基酸侧链之间的复杂物理化学作用力(如氢键、疏水作用、二硫键和范德华力),折叠成精确的三维立体结构。只有形成了特定的三维结构,蛋白质才具备生物学活性。

在网页端渲染的意义及作用

蛋白质结构可视化是分子生物学、结构生物学和现代制药领域的核心工具。其最根本的意义在于生物学中的核心法则: "结构决定功能" 。

将一维的氨基酸序列转化为直观的三维模型,具有以下几个关键层面的意义:

  1. 揭示蛋白质的工作机制与功能活性 蛋白质在空间中的折叠方式直接决定了它能做什么。通过可视化工具,科学家可以直观地观察到蛋白质表面的凹槽、孔洞或特定的电荷分布。这些三维特征构成了蛋白质的"活性位点",解释了它们如何催化化学反应或传递细胞信号。
  2. 驱动基于结构的药物设计 (SBDD) 这是结构可视化最直接的医疗和商业应用。以细胞色素 P450 3A4 (1TQN) 为例,这种酶负责代谢人体内近一半的临床药物。通过可视化其内部的结合口袋以及核心的血红素辅基(HEM),药物化学家可以像"配钥匙"一样,利用计算机模拟设计出能够精确嵌入该位点的小分子药物,从而抑制或激活该分子的活性
  3. 解析疾病机理与基因突变的影响 许多严重的遗传性疾病(如镰刀型细胞贫血症或某些癌症)仅仅是由蛋白质序列中单个氨基酸的突变引起的。通过 3D 可视化,研究人员可以准确定位突变发生的空间位置,直观地看出该突变是否破坏了蛋白质的稳定性、阻碍了配体的结合,或者是引起了蛋白质的异常聚集。
  4. 展示复杂的分子间相互作用 蛋白质很少孤立发挥作用。可视化技术能够清晰地呈现蛋白质与其周围环境的相互作用。
  5. 指导蛋白质工程与合成生物学 在工业应用中,科学家经常需要改造天然的酶,以提高其耐热性或改变其催化特性。结构可视化提供了这台"分子机器"的详细蓝图,指导研究人员在特定的 3D 坐标上进行定向的氨基酸替换,而不是盲目尝试。

蛋白质结构存储文件格式的介绍

目前主流的蛋白质存储文件格式主要有PDB和mmCIF两种,其中,mmCIF 是官方标准,PDB 是历史遗产。2019 年起新提交的大结构 只接受 mmCIF ,老 PDB 格式已冻结------不再扩展,很多新条目(原子数超过 99999 或链 ID 超过 62 的)根本没有.pdb 文件可下载。

PDB 格式在科研圈和教学圈仍然遍地都是。原因很现实:定宽列、肉眼可读、几十行代码就能解析,老软件老脚本全是它。你随便打开一个分子工具,导入 PDB 永远支持。

本文中采用的案例也是PDB格式的,包含5万多个原子级的蛋白质文件结构。

PDB 格式介绍

PDB是定宽列的文本格式,不同类型的记录对应不同的结构

  1. ATOM 记录------每个原子一行
列号 1-6 7-11 13-16 18-20 22 23-26 31-38 39-46 47-54 55-60 61-66 77-78
示例 ATOM 1 N THR A 1 17.047 14.099 3.625 1.00 13.79 N
含义 记录名 序号 原子名 残基名 链ID 残基号 x y z 占有率 温度因子 元素
  1. HELIX / SHEET 记录------二级结构 (彩带模型用)

HELIX 记录

列号 1-6 8-10 12-14 16-18 20 22-25 26 28-30 32 34-37 38 39-40 41-70 72-76
示例 HELIX 1 1 GLY A 9 THR A 30 1 22
含义 记录名 helix序号 helix标识符 起始残基名 起始链ID 起始残基号 起始插入码 终止残基名 终止链ID 终止残基号 终止插入码 helix类型 注释 长度

SHEET 记录

列号 1-6 8-10 12-14 15-16 18-20 22 23-26 27 29-31 33 34-37 38 39-40
示例 SHEET 1 A 2 LYS A 4 PHE A 8 0
含义 记录名 链序号 sheet标识 链数 起始残基名 起始链ID 起始残基号 起始插入码 终止残基名 终止链ID 终止残基号 终止插入码 方向(sense)

这里有几个比较关键的字段需要我们知道:

  • 序号(atom)/helix序号(helix)/链序号(sheet). 对于原子记录来说,序号就是整张表的全局ID了,另外两个helix和sheet记录来说是注释性编号,
  • 原子名(ATOM记录) 原子的化学名称,如 CA(阿尔法碳)、N(氮)、O(氧)。 对于渲染来说比较重要,每条链的走向跟着CA走,不同原子的大小,渲染的色值也是不一样的。
  • 残基名 该原子所属的氨基酸或核酸的三字母简写(如丙氨酸 ALA)
  • 链ID 蛋白质不同亚基或多肽链的标识符(如 A链、B链)。 链是构成蛋白质的二级结构
  • 坐标 原子空间位置的xyz坐标

在网页端的渲染

在网页上展示一段带白纸结构,目前主要有两种模型: 球棍模型和彩带模型。

一、 球棍模型(Ball-and-Stick Model)

1. 结构说明

  • 呈现内容 :渲染出蛋白质中几乎所有的重原子 (碳 C、氮 N、氧 O、硫 S 等)以及它们之间的共价键。

  • 视觉特征:

    • 球(Spheres) :代表原子,依据 CPK 规则着色(如碳灰、氧红、氮蓝),且为了避免互相遮挡,通常会将半径缩小到范德华半径的 20%~30%。
    • 棍(Sticks) :代表化学键,通常用细圆柱体连接相邻的原子。
  • 数据来源 :主要依赖 PDB 文件中的 ATOM 记录(全部原子坐标)和 CONECT 记录(或通过空间距离算法计算键连关系)。

2. 核心用途

  • 研究活性中心与催化机制:当科学家需要观察蛋白质是如何与小分子药物、辅酶或底物发生结合时,必须放大到这个精度。
  • 分析侧链相互作用:观察氨基酸的侧链(R基团)之间是如何形成氢键、盐桥或疏水作用的。
  • 局部突变与残基微观构象:检查某个特定氨基酸发生突变后,其周围的原子空间排布是否发生了挤压或冲突。

3. 渲染过程

渲染一个完整的球棍模型,考虑到蛋白质实际包含的原子个数--动辄几万十几万的量级,不能单纯一个mesh一个mesh去渲染,否则draw call必然爆炸,必须借助instanceMesh将大部分计算工作交给GPU去弄。材质选择上为了呈现出有体积感非金属塑料球并有物理反射且性能相对较好选择了meshStandardMaterial,

具体的借助如下元素完成原子渲染

JSX 复制代码
<instancedMesh
  key={count}
  ref={meshRef}
  args={[undefined, undefined, count]}
  frustumCulled={false}
  visible={visible}
  userData={{ pickType: 'atom' }}
>
  <sphereGeometry args={[1, 12, 12]} />
  <meshStandardMaterial roughness={0.4} metalness={0.1} />
</instancedMesh>

借助如下元素完成连接的渲染

JSX 复制代码
<instancedMesh
  key={count}
  ref={meshRef}
  args={[undefined, undefined, count]}
  frustumCulled={false}
  visible={visible}
  userData={{ pickType: 'atom' }}
>
  <sphereGeometry args={[1, 12, 12]} />
  <meshStandardMaterial roughness={0.4} metalness={0.1} />
</instancedMesh>

threejs元素选择完成之后,接下来,需要真正的去渲染;我们需要在pdb文件中解析出所需要的原子属性:

typescript 复制代码
export interface Atom {
  index: number; // 数组下标
  serial: number; // PDB 里的序号
  name: string; // 原子名称 'CA' | 'N' ...
  element: string; // 元素符号 → 决定颜色和半径
  position: [number, number, number]; // x, y, z(埃 Å 单位)
  residueIndex: number; // 指回 residues 数组
  chainIndex: number;
}

拿到数据之后作出处理: 根据质心平移所有顶点坐标,根据原子类型匹配具体的原子半径和颜色:

typescript 复制代码
const { positions, scales, colors } = useMemo(() => {
    const positions = new Float32Array(count * 3);
    const scales = new Float32Array(count);
    const colors = new Float32Array(count * 3);
    const [cx, cy, cz] = center;

    const color = new Color();
    for (let i = 0; i < count; i++) {
      const atom = model.atoms[i];
      const style = ELEMENT_STYLE[atom.element] ?? FALLBACK;
      positions[i * 3] = atom.position[0] - cx;
      positions[i * 3 + 1] = atom.position[1] - cy;
      positions[i * 3 + 2] = atom.position[2] - cz;
      scales[i] = style.radius * RADIUS_SCALE;
      color.set(style.color);
      colors[i * 3] = color.r;
      colors[i * 3 + 1] = color.g;
      colors[i * 3 + 2] = color.b;
    }
    return { positions, scales, colors };
  }, [model, count]);

然后更新原子对应的instanceMesh的matrix和colors:

typescript 复制代码
useLayoutEffect(() => {
    const mesh = meshRef.current;
    if (!mesh) return;
    const dummy = new Object3D();
    const color = new Color();
    for (let i = 0; i < count; i++) {
      dummy.position.set(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
      dummy.scale.setScalar(scales[i]);
      dummy.updateMatrix();
      mesh.setMatrixAt(i, dummy.matrix);
      mesh.setColorAt(i, color.setRGB(colors[i * 3], colors[i * 3 + 1], colors[i * 3 + 2]));
    }
    mesh.instanceMatrix.needsUpdate = true;
    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
  }, [positions, scales, colors, count]);

这样就很容易的将原子模型渲染出来了,然后球 渲染完了,就是棍 的渲染:

几何体上选择使用圆柱实现,由于PDB文件CONECT 只强制要求记录 HETATM 的连接;标准残基内部的键由 残基化学模板隐含,所以数据需要进一步处理: 根据原子空间模型推断出每对原子之间的连接(本身的科学依据是:两原子距离 < (共价半径A + 共价半径B) × 1.3 → 成键)

算法实现上,如果原子间两两比较,5万数量级的On方的时间复杂度,计算次数会达到17亿这个级别,因此最终实现上使用空间网格将所有原子进行一一划分,然后遍历所有原子,每个原子只比较周围27个原子,若两原子距离 < (共价半径A + 共价半径B) × 1.3 → 成键 , 即组成连接。

关键代码摘要:

  • threejs 元素选择
jsx 复制代码
<instancedMesh
  key={count}
  ref={meshRef}
  args={[undefined, undefined, count]}
  frustumCulled={false}
  visible={visible}
  userData={{ pickType: 'bond' }}
>
  <cylinderGeometry args={[1, 1, 1, 8, 1, true]} />
  <meshStandardMaterial roughness={0.4} metalness={0.1} />
</instancedMesh>
  • 网格建模部分
typescript 复制代码
for (const a of atoms) {
    const rA = COVALENT_RADIUS[a.element] ?? FALLBACK_RADIUS;
    const [ax, ay, az] = a.position;
    const cx = Math.floor(ax / cell);
    const cy = Math.floor(ay / cell);
    const cz = Math.floor(az / cell);

    // 只查周围 27 个格子
    for (let dx = -1; dx <= 1; dx++) {
      for (let dy = -1; dy <= 1; dy++) {
        for (let dz = -1; dz <= 1; dz++) {
          const bucket = grid.get(`${cx + dx},${cy + dy},${cz + dz}`);
          if (!bucket) continue;
          for (const j of bucket) {
            if (j <= a.index) continue; // 每对只判一次
            const b = atoms[j];
            const rB = COVALENT_RADIUS[b.element] ?? FALLBACK_RADIUS;
            const cutoff = (rA + rB) * SLACK;
            const ddx = b.position[0] - ax;
            const ddy = b.position[1] - ay;
            const ddz = b.position[2] - az;
            const distSq = ddx * ddx + ddy * ddy + ddz * ddz;
            if (distSq > MIN_DIST * MIN_DIST && distSq < cutoff * cutoff) {
              bonds.push([a.index, j]);
            }
          }
        }
      }
    }
  }

有了每对的连接关系之后,下一步就是用两个圆柱体组成一个圆柱体的方式将其连接起来,一般两个圆柱体之间,靠近a原子的部分跟a是一个颜色,靠近b原子的部分跟b是一个颜色, 计算细节要注意的就是 将圆柱从y轴朝向,转化为从a -> b的朝向,利用ab坐标算出两个圆柱的位置,并更新matrix和color属性:

typescript 复制代码
 const mesh = meshRef.current;
    if (!mesh) return;
    const dummy = new Object3D();
    const color = new Color();
    const dir = new Vector3();
    const quat = new Quaternion();
    const [cx, cy, cz] = center;

    bonds.forEach(([ia, ib], bi) => {
      const a = model.atoms[ia];
      const b = model.atoms[ib];
      const ax = a.position[0] - cx;
      const ay = a.position[1] - cy;
      const az = a.position[2] - cz;
      const bx = b.position[0] - cx;
      const by = b.position[1] - cy;
      const bz = b.position[2] - cz;

      dir.set(bx - ax, by - ay, bz - az);
      const len = dir.length();
      dir.normalize();
      quat.setFromUnitVectors(Y_AXIS, dir);

      // 前半段:靠原子 a,染 a 的元素色
      dummy.quaternion.copy(quat);
      dummy.scale.set(STICK_RADIUS, len / 2, STICK_RADIUS);
      dummy.position.set(ax + (dir.x * len) / 4, ay + (dir.y * len) / 4, az + (dir.z * len) / 4);
      dummy.updateMatrix();
      mesh.setMatrixAt(bi * 2, dummy.matrix);
      mesh.setColorAt(bi * 2, color.set(ELEMENT_COLOR[a.element] ?? FALLBACK_COLOR));

      // 后半段:靠原子 b,染 b 的元素色
      dummy.position.set(bx - (dir.x * len) / 4, by - (dir.y * len) / 4, bz - (dir.z * len) / 4);
      dummy.updateMatrix();
      mesh.setMatrixAt(bi * 2 + 1, dummy.matrix);
      mesh.setColorAt(bi * 2 + 1, color.set(ELEMENT_COLOR[b.element] ?? FALLBACK_COLOR));
    });

最终渲染效果:

二、 彩带模型 / 卡通模型(Ribbon / Cartoon Model)

1. 结构说明

  • 呈现内容 :忽略绝大多数繁杂的侧链原子,仅提取并平滑连接主干骨架(尤其是 \(\text{C}_\alpha\) 原子) ,并根据二级结构赋予不同的几何形态。

  • 视觉特征:

    • \(\alpha\)-螺旋(Alpha Helix) :渲染成规则的螺旋状管子或粗螺旋面。
    • \(\beta\)-折叠(Beta Sheet) :渲染成带箭头的扁平飘带,箭头指向羧基端,直观展示方向性。
    • 无规则卷曲(Coil / Loop) :渲染成细的平滑曲线或细管,连接各个二级结构元件。
  • 数据来源 :依赖 HELIX 和 SHEET 记录,或者通过算法(如 DSSP)实时计算主干走向。

2. 核心用途

  • 宏观架构与整体折叠(Global Fold) :一眼看出整个蛋白质的空间整体三维形状、域(Domain)的划分以及四级结构的亚基组装方式。
  • 大分子对比(Structural Alignment) :当把两个相似的蛋白质叠放在一起时,用彩带模型能最快看出它们的整体构象是否发生漂移或形变。
  • 学术演示与出版物出图:这是分子生物学论文中最常见的展示形式,能瞬间抓住视觉重点,避免被几万个原子的视觉噪点干扰。

3. 渲染过程

彩带模型的渲染,跟球棍模型不同的是mesh个数不会太多,但一个比较难的点是沿着Cå构建飘带,和顺着Cå到O的方向构建彩带的带宽,因为没有现成的模型能用,需要我们自己手动使用代码建模。 自定义positions, normals 以及colors和indices;

几者的作用是: positions 是顶点坐标,normals 是逐顶点法向量供光照插值,colors 按二级结构逐顶点上色(α螺旋品红、β折叠黄色、无规卷曲灰色),indices 把顶点下标组成三角形网格------四者合成一份索引化 BufferGeometry;

核心思路是这样的,整体的实现上都是使用超椭圆一个环一个环套在Cå周围,然后使用三角面缝合前后两个环据此形成一条彩带,不同结构的椭圆大小和色彩不一样,然后ß折叠在其末尾加一个箭头即可。

这里主要贴一下彩带的关键算法:

  • 按链提取控制点并按 Cå 间距切段
    这一步主要为了获取构建沿Cå超椭圆构建的几个关键参数的依赖值: 宽度(方向Cå -> O)严格垂直于Cå, 由单位向量W表示其方向,由Cå切线方向和Cå -> O 的单位向量二次叉积得出; 还有厚度,严格垂直于Cå切线跟W正交构成的平面,由单位向量N表示其方向;

细节代码:

typescript 复制代码
// 提取一条链的 CA 控制点(跳过无 CA 的 HETATM 残基)
function chainControlPoints(model: MoleculeModel, chainIndex: number): ControlPoint[] {
  const pts: ControlPoint[] = [];
  for (const r of model.residues) {
    if (r.chainIndex !== chainIndex || r.caIndex === -1) continue;
    const ca = model.atoms[r.caIndex].position;
    let wdir: V3 | null = null;
    if (r.oIndex !== -1) {
      wdir = norm(sub(model.atoms[r.oIndex].position, ca));
    }
    pts.push({ pos: ca, wdir, ss: r.ss });
  }
  return pts;
}
// 1) 按链提取控制点并按 CA 间距切段
 const segments: Sample[][] = [];
    for (let ci = 0; ci < model.chains.length; ci++) {
    const pts = chainControlPoints(model, ci);
    let start = 0;
    for (let i = 0; i < pts.length - 1; i++) {
      // 蛋白质主链上相邻两个 Cα 的距离基本恒定在 3.8Å 左右(CA 是虚拟键,由几何约束决定)。
      if (len(sub(pts[i + 1].pos, pts[i].pos)) > MAX_CA_JUMP) {
        if (i + 1 - start >= 2) segments.push(sampleSegment(pts.slice(start, i + 1)));
        start = i + 1;
      }
    }
    // 小于2个的直接丢弃
    if (pts.length - start >= 2) segments.push(sampleSegment(pts.slice(start)));
}

此处为了使彩带更加光滑,进行一次插值算法:

typescript 复制代码
// 一段无断点控制点 → 曲线采样
function sampleSegment(pts: ControlPoint[]): Sample[] {
  const n = pts.length;
  const wdirs = fillWdirs(pts);
  const samples: Sample[] = [];
  let prevTan: V3 = [0, 0, 1];

  const pushSample = (cr: { pos: V3; tan: V3 }, ss: SecondaryStructure, w: V3 | null) => {
    const tan = norm(cr.tan) ?? prevTan; // 零切向(重复点)沿用上一个
    prevTan = tan;
    samples.push({ pos: cr.pos, tan, wdir: w, ss, widthScale: 1 });
  };

  for (let i = 0; i < n - 1; i++) {
    const p0 = pts[Math.max(i - 1, 0)].pos;
    const p1 = pts[i].pos;
    const p2 = pts[i + 1].pos;
    const p3 = pts[Math.min(i + 2, n - 1)].pos;
    for (let s = 0; s < SAMPLES_PER_RES; s++) {
      const t = s / SAMPLES_PER_RES;
      const ss = t < 0.5 ? pts[i].ss : pts[i + 1].ss;
      const wRaw = wdirs[i] && wdirs[i + 1] ? norm(lerpV(wdirs[i] as V3, wdirs[i + 1] as V3, t)) : null;
      pushSample(catmullRom(p0, p1, p2, p3, t), ss, wRaw);
    }
  }
  // 段尾终点(t=1 解析值,保证段末有环可缝合)
  const p0 = pts[Math.max(n - 3, 0)].pos;
  const p1 = pts[n - 2].pos;
  const p2 = pts[n - 1].pos;
  const p3 = p2;
  pushSample(catmullRom(p0, p1, p2, p3, 1), pts[n - 1].ss, wdirs[n - 1]);
  return samples;
}

function catmullRom(p0: V3, p1: V3, p2: V3, p3: V3, t: number): { pos: V3; tan: V3 } {
  const t2 = t * t;
  const t3 = t2 * t;
  const pos: V3 = [0, 0, 0];
  const tan: V3 = [0, 0, 0];
  for (let d = 0; d < 3; d++) {
    pos[d] =
      0.5 *
      (2 * p1[d] +
        (-p0[d] + p2[d]) * t +
        (2 * p0[d] - 5 * p1[d] + 4 * p2[d] - p3[d]) * t2 +
        (-p0[d] + 3 * p1[d] - 3 * p2[d] + p3[d]) * t3);
    tan[d] =
      0.5 *
      (-p0[d] +
        p2[d] +
        2 * (2 * p0[d] - 5 * p1[d] + 4 * p2[d] - p3[d]) * t +
        3 * (-p0[d] + 3 * p1[d] - 3 * p2[d] + p3[d]) * t2);
  }
  return { pos, tan };
}

catmullRom是一个经典的差值算法,插值点由p0, p3控制弯曲方向, 必经过p1, p2点。

接下来需要给ß折叠结构构造出箭头(由N到C), 然后计算W N向量,实现细节上采用平行传输帧系防止彩带出现扭转反转现象;

W由wdir和其自身切线方向T经过二次叉积得出,这是因为, wdir本身并不跟T严格垂直,计算上首先由T叉积Wdir得到临时法向量N0,此时N0必严格垂直于T, 然后由N0再叉积T,此时得到的W才严格垂直于T,这是由向量叉积性质得出的;

平行传输帧要注意的计算要点就是, 我们计算当前Cå的W向量, 需要由上个Cå的W-1得出, 这样主要是为了让前后彩带处的变动更加丝滑, 这里的数学原理是向量加减法, 我们可以将W向量分解为平行当前Cå切线T的向量X和垂直于T的向量Y,这样计算出来的向量Y就是我们要求的Cå向量的W, 实现上,由W和T的点积计算出W在T方向的投影长度,然后点乘向量T得到X向量之后, 再被W减掉归一化之后即可得出W

代码细节:

typescript 复制代码
// 箭头必须摊开约 2 个残基(~7Å)才肉眼可辨,单环突变在大结构上缩没
function applyArrows(samples: Sample[]): void {
  let i = 0;
  while (i < samples.length) {
    if (samples[i].ss !== 'sheet') {
      i++;
      continue;
    }
    let j = i;
    while (j + 1 < samples.length && samples[j + 1].ss === 'sheet') j++;
    const ramp = Math.min(ARROW_RAMP_SAMPLES, j - i);
    const rampStart = j - ramp + 1;
    for (let s = rampStart; s <= j; s++) {
      const k = (s - rampStart) / Math.max(ramp - 1, 1);
      samples[s].widthScale = 1 + (ARROW_FLARE - 1) * k;
    }
    samples.splice(j + 1, 0, {
      pos: add(samples[j].pos, scale(samples[j].tan, ARROW_TIP_LEN)),
      tan: samples[j].tan,
      wdir: samples[j].wdir,
      ss: 'sheet',
      widthScale: 0,
    });
    i = j + 2;
  }
}

  const positions = new Float32Array(totalSamples * RING * 3);
  const normals = new Float32Array(totalSamples * RING * 3);
  const colors = new Float32Array(totalSamples * RING * 3);
  const indices = new Uint32Array((totalSamples - segments.length) * RING * 6);

  //  出顶点:帧系 T/W/N 正交化,超椭圆截面
  let v = 0; // 顶点游标(float 下标)
  let ii = 0; // 索引游标
  let segStartVertex = 0;
  const pow = Math.pow;

  for (const seg of segments) {
    // 平行传输帧系(rotation-minimizing frame):段首定种子带宽方向,
    // 之后每环仅做"去掉切向分量"的最小旋转传输。带宽不再逐环跟随 CA→O,
    // 消除 β-strand 羰基交替朝向导致的左右摆动;传输连续,无需防扭转翻转。
    let prevW: V3 | null = null;
    for (let s = 0; s < seg.length; s++) {
      const smp = seg[s];
      const style = STYLE[smp.ss];

      const T = smp.tan;
      let W: V3;
      if (prevW) {
        // 传输:上一环 W 减去切向分量后归一化,投到当前切平面
        W = norm(sub(prevW, scale(T, dot(prevW, T)))) ?? anyPerp(T);
      } else {
        // 种子帧:段首环用 wdir(CA→O)正交化,缺 O 时任意垂直兜底
        const N0 = smp.wdir ? norm(cross(T, smp.wdir)) : anyPerp(T);
        W = norm(cross(N0 as V3, T)) ?? anyPerp(T);
      }
      prevW = W;
      const N = cross(T, W); // 手性与原实现一致:T×W=N

      const w = style.w * smp.widthScale;
      const th = style.t * (smp.widthScale > 1 ? 1 : smp.widthScale);
      const px = smp.pos[0] - cx;
      const py = smp.pos[1] - cy;
      const pz = smp.pos[2] - cz;
      const [r, g, b] = style.color;

      for (let k = 0; k < RING; k++) {
        const ang = (k / RING) * Math.PI * 2;
        const c = Math.cos(ang);
        const sn = Math.sin(ang);
        const fx = style.e === 1 ? c : Math.sign(c) * pow(Math.abs(c), 2 / style.e);
        const fy = style.e === 1 ? sn : Math.sign(sn) * pow(Math.abs(sn), 2 / style.e);

        const ox = W[0] * w * fx + N[0] * th * fy;
        const oy = W[1] * w * fx + N[1] * th * fy;
        const oz = W[2] * w * fx + N[2] * th * fy;
        positions[v] = px + ox;
        positions[v + 1] = py + oy;
        positions[v + 2] = pz + oz;

        // 伪法线:截面偏移方向(尖端退化时用 N 兜底)
        const nrm = norm([ox, oy, oz]) ?? N;
        normals[v] = nrm[0];
        normals[v + 1] = nrm[1];
        normals[v + 2] = nrm[2];

        colors[v] = r;
        colors[v + 1] = g;
        colors[v + 2] = b;
        v += 3;
      }

      // 与上一环缝合成三角带
      if (s > 0) {
        const prevRing = segStartVertex + (s - 1) * RING;
        const curRing = segStartVertex + s * RING;
        for (let k = 0; k < RING; k++) {
          const a = prevRing + k;
          const b = prevRing + ((k + 1) % RING);
          const a2 = curRing + k;
          const b2 = curRing + ((k + 1) % RING);
          indices[ii++] = a;
          indices[ii++] = a2;
          indices[ii++] = b;
          indices[ii++] = b;
          indices[ii++] = a2;
          indices[ii++] = b2;
        }
      }
    }
    segStartVertex += seg.length * RING;
  }

渲染结果

预览地址